Skip to content

Commit d5dd182

Browse files
committed
Complete remediation follow-up hardening
1 parent 0926bb2 commit d5dd182

16 files changed

Lines changed: 178 additions & 35 deletions

.github/workflows/ci.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ jobs:
1818
python -m pip install -e .[dev]
1919
- name: Ruff
2020
run: ruff check .
21+
- name: Bandit web hardening scan
22+
run: bandit -r -ll src/pcap2llm/web/
2123

2224
test:
2325
runs-on: ubuntu-latest

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ The format is intentionally simple and optimized for humans reading repo history
66

77
## Unreleased
88

9+
### Fixed — 2026-04-25 (remediation hardening follow-up)
10+
11+
- Completed the remediation-plan hardening pass:
12+
- `share` now pseudonymizes endpoint identifiers (`ip`, `hostname`) as well as subscriber identifiers.
13+
- summary conversations are covered by a pipeline regression test that verifies no raw endpoint IPs remain.
14+
- burst detection now uses packet order instead of timestamp sorting, including reordered-capture coverage.
15+
- web support-file paths are limited to per-job uploads plus `PCAP2LLM_WEB_SUPPORT_FILES_ROOT` / local workspace roots.
16+
- external LLM handoff commands now have regression coverage for refusing unsafe keep modes unless explicitly overridden.
17+
- profile recommendation hard gates and dominant signaling protocol selection have direct regression coverage.
18+
- CI now runs a Bandit high-confidence web hardening scan with `bandit -r -ll src/pcap2llm/web/`.
19+
- Privacy and sharing docs were updated to reflect that `share` pseudonymizes endpoints.
20+
921
### Fixed — 2026-04-24 (web runtime and local smoke checks)
1022

1123
- **Web GUI runtime compatibility**:

docs/ANLEITUNG_DE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@ Privacy ist absichtlich von der Profilwahl getrennt.
243243
Typische Startpunkte:
244244

245245
- `internal`: lokal, unveraendert
246-
- `share`: intern teilen, Subscriber-Daten pseudonymisieren
246+
- `share`: intern teilen, Endpunkte und Subscriber-Daten pseudonymisieren
247247
- `prod-safe`: staerker schuetzen, bevor du nach aussen gehst
248248
- `llm-telecom-safe`: guter Standard fuer externe LLMs
249249

docs/PRIVACY_SHARING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Related docs:
1616

1717
| Scenario | Recommended profile | Notes |
1818
|---|---|---|
19-
| Internal team troubleshooting | `share` | Good default for most internal work |
19+
| Internal team troubleshooting | `share` | Good default for most internal work; endpoint and subscriber identifiers are pseudonymized |
2020
| Vendor ticket | `prod-safe` | Remove tokens, reduce sensitive metadata |
2121
| Lab replay / test environment | `lab` | Stronger anonymization, still useful context |
2222
| Personal local analysis | `internal` | Only in fully trusted environments |

docs/REFERENCE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ For full details and supported types, see [`NETWORK_ELEMENT_DETECTION.md`](NETWO
753753
| Profile | What it does |
754754
|---|---|
755755
| `internal` | Keep everything as-is |
756-
| `share` | Pseudonymize subscriber IDs (IMSI, MSISDN), remove tokens |
756+
| `share` | Pseudonymize endpoints and subscriber IDs (IP, hostnames, IMSI, MSISDN), remove tokens |
757757
| `lab` | Pseudonymize all subscriber data, mask IPs |
758758
| `prod-safe` | Maximum protection — mask IPs, pseudonymize all PII, remove tokens/email/URI/payload |
759759
| `llm-telecom-safe` | External LLM-safe default — pseudonymize endpoints and subscriber IDs, remove secrets/payload, keep telecom structure |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ Issues = "https://github.com/FrnkMrz/pcap2llm/issues"
4141
dev = [
4242
"pytest>=8.0",
4343
"ruff>=0.5",
44+
"bandit>=1.7",
4445
"build>=1.2",
4546
"httpx>=0.28"
4647
]

src/pcap2llm/privacy_profiles/share.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ description: >
33
Safe for sharing with external parties or cross-team review.
44
Subscriber identifiers are pseudonymized; tokens and bearer-text removed.
55
modes:
6-
ip: keep
7-
hostname: keep
6+
ip: pseudonymize
7+
hostname: pseudonymize
88
subscriber_id: pseudonymize
99
msisdn: pseudonymize
1010
imsi: pseudonymize

src/pcap2llm/summarizer.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,39 +45,42 @@ def _detect_bursts(
4545
4646
Returns a list of burst descriptors ``{start_ms, end_ms, packet_count}``.
4747
"""
48-
sorted_times = sorted(
48+
times = [
4949
p["time_rel_ms"]
5050
for p in detail_packets
5151
if isinstance(p.get("time_rel_ms"), (int, float))
52-
)
53-
if len(sorted_times) < min_burst_size:
52+
]
53+
if len(times) < min_burst_size:
5454
return []
5555

5656
bursts: list[dict[str, Any]] = []
57-
burst_start = sorted_times[0]
57+
burst_start = times[0]
58+
burst_end = times[0]
5859
burst_count = 1
5960

60-
for i in range(1, len(sorted_times)):
61-
gap = sorted_times[i] - sorted_times[i - 1]
62-
if gap <= threshold_ms:
61+
for i in range(1, len(times)):
62+
gap = times[i] - times[i - 1]
63+
if 0 <= gap <= threshold_ms:
6364
burst_count += 1
65+
burst_end = times[i]
6466
else:
6567
if burst_count >= min_burst_size:
6668
bursts.append(
6769
{
6870
"start_ms": round(burst_start, 3),
69-
"end_ms": round(sorted_times[i - 1], 3),
71+
"end_ms": round(burst_end, 3),
7072
"packet_count": burst_count,
7173
}
7274
)
73-
burst_start = sorted_times[i]
75+
burst_start = times[i]
76+
burst_end = times[i]
7477
burst_count = 1
7578

7679
if burst_count >= min_burst_size:
7780
bursts.append(
7881
{
7982
"start_ms": round(burst_start, 3),
80-
"end_ms": round(sorted_times[-1], 3),
83+
"end_ms": round(burst_end, 3),
8184
"packet_count": burst_count,
8285
}
8386
)

src/pcap2llm/web/app.py

Lines changed: 10 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,6 @@ async def show_job(request: Request, job_id: str) -> HTMLResponse:
242242
"downloads": store.list_download_entries(record),
243243
"log_sections": _collect_log_sections(store.logs_dir(job_id)),
244244
"flow_svg": _first_matching(store.artifacts_dir(job_id), ".svg"),
245-
"flow_svg_markup": _read_flow_svg_markup(store.artifacts_dir(job_id)),
246245
"settings": settings,
247246
"analyze_defaults": analyze_defaults,
248247
}
@@ -305,6 +304,7 @@ async def run_analyze(
305304
local_support_defaults = _local_support_file_defaults(settings)
306305
hosts_path = await _resolve_support_file(
307306
store=store,
307+
settings=settings,
308308
record=record,
309309
label="hosts",
310310
raw_path=hosts_file,
@@ -313,6 +313,7 @@ async def run_analyze(
313313
)
314314
mapping_path = await _resolve_support_file(
315315
store=store,
316+
settings=settings,
316317
record=record,
317318
label="mapping",
318319
raw_path=mapping_file,
@@ -321,6 +322,7 @@ async def run_analyze(
321322
)
322323
subnets_path = await _resolve_support_file(
323324
store=store,
325+
settings=settings,
324326
record=record,
325327
label="subnets",
326328
raw_path=subnets_file,
@@ -329,6 +331,7 @@ async def run_analyze(
329331
)
330332
ss7pcs_path = await _resolve_support_file(
331333
store=store,
334+
settings=settings,
332335
record=record,
333336
label="ss7pcs",
334337
raw_path=ss7pcs_file,
@@ -337,6 +340,7 @@ async def run_analyze(
337340
)
338341
network_element_mapping_path = await _resolve_support_file(
339342
store=store,
343+
settings=settings,
340344
record=record,
341345
label="network-element-mapping",
342346
raw_path=network_element_mapping_file,
@@ -931,6 +935,7 @@ def _parse_optional_float(value: str) -> float | None:
931935
async def _resolve_support_file(
932936
*,
933937
store: JobStore,
938+
settings: WebSettings,
934939
record: JobRecord,
935940
label: str,
936941
raw_path: str,
@@ -954,13 +959,14 @@ async def _resolve_support_file(
954959
candidate = text or default_path
955960
if not candidate:
956961
return None
957-
return str(_validate_support_path(candidate, store=store, record=record))
962+
return str(_validate_support_path(candidate, store=store, settings=settings, record=record))
958963

959964

960-
def _validate_support_path(value: str, *, store: JobStore, record: JobRecord) -> Path:
965+
def _validate_support_path(value: str, *, store: JobStore, settings: WebSettings, record: JobRecord) -> Path:
961966
path = Path(value)
962967
resolved = path.resolve()
963-
allowed_roots = (store.support_dir(record.job_id), store.workdir.parent / ".local", store.workdir.parent)
968+
configured_root = settings.support_files_root or settings.local_workspace_dir
969+
allowed_roots = (store.support_dir(record.job_id), configured_root)
964970
for root in allowed_roots:
965971
try:
966972
return ensure_within(root, resolved)
@@ -1064,20 +1070,6 @@ def _read_log(path: Path) -> str:
10641070
return data[-8000:]
10651071

10661072

1067-
def _read_flow_svg_markup(folder: Path) -> str | None:
1068-
filename = _first_matching(folder, ".svg")
1069-
if not filename:
1070-
return None
1071-
path = folder / filename
1072-
data = path.read_text(encoding="utf-8", errors="replace")
1073-
lowered = data.lower()
1074-
if not data.lstrip().startswith("<svg"):
1075-
return None
1076-
if "<script" in lowered or "javascript:" in lowered:
1077-
return None
1078-
return data
1079-
1080-
10811073
def _collect_log_sections(logs_dir: Path) -> list[dict[str, str]]:
10821074
sections: list[dict[str, str]] = []
10831075
for prefix, label in (

src/pcap2llm/web/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class WebSettings:
1313
max_upload_mb: int = 1
1414
command_timeout_seconds: int = 600
1515
tshark_path: str = ""
16+
support_files_root: Path | None = None
1617
default_privacy_profile: str = "share"
1718
cleanup_enabled: bool = True
1819
cleanup_max_age_days: int = 7
@@ -47,6 +48,8 @@ def load_settings() -> WebSettings:
4748
max_upload_mb = int(os.getenv("PCAP2LLM_WEB_MAX_UPLOAD_MB", "1"))
4849
command_timeout_seconds = int(os.getenv("PCAP2LLM_WEB_COMMAND_TIMEOUT_SECONDS", "600"))
4950
tshark_path = os.getenv("PCAP2LLM_WEB_TSHARK_PATH", "")
51+
support_files_root_env = os.getenv("PCAP2LLM_WEB_SUPPORT_FILES_ROOT", "")
52+
support_files_root = Path(support_files_root_env) if support_files_root_env else None
5053
default_privacy_profile = os.getenv("PCAP2LLM_WEB_DEFAULT_PRIVACY_PROFILE", "share")
5154
cleanup_enabled = os.getenv("PCAP2LLM_WEB_CLEANUP_ENABLED", "true").lower() in ("true", "1", "yes")
5255
cleanup_max_age_days = int(os.getenv("PCAP2LLM_WEB_CLEANUP_MAX_AGE_DAYS", "7"))
@@ -58,6 +61,7 @@ def load_settings() -> WebSettings:
5861
max_upload_mb=max_upload_mb,
5962
command_timeout_seconds=command_timeout_seconds,
6063
tshark_path=tshark_path,
64+
support_files_root=support_files_root,
6165
default_privacy_profile=default_privacy_profile,
6266
cleanup_enabled=cleanup_enabled,
6367
cleanup_max_age_days=cleanup_max_age_days,

0 commit comments

Comments
 (0)