Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}'
```
Expand Down Expand Up @@ -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]
Expand All @@ -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`) |
Expand Down
90 changes: 72 additions & 18 deletions hubble_audit2policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@

from __future__ import annotations

__version__ = "0.7.5"
__version__ = "0.8.0"
__author__ = "noexecstack"
__license__ = "Apache-2.0"

import argparse
import base64
import curses
import dataclasses
import datetime
import io
import json
import logging
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions tests/test_flow_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down
Loading