Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70e38ce74a | |||
| ab14186aa7 |
@@ -6,12 +6,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import re
|
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Iterator
|
from typing import Iterator
|
||||||
|
|
||||||
from ...log import die, info
|
from ...log import die, info
|
||||||
|
from ...util import slugify
|
||||||
|
|
||||||
|
|
||||||
def run_docker(
|
def run_docker(
|
||||||
@@ -114,19 +114,6 @@ def docker_cp(src: str, dest: str) -> None:
|
|||||||
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
f"{(result.stderr or '').strip() or '<no stderr>'}")
|
||||||
|
|
||||||
|
|
||||||
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
|
||||||
|
|
||||||
|
|
||||||
def slugify(name: str) -> str:
|
|
||||||
"""Lowercase, non-alnum runs → '-', trimmed. Dies on empty result."""
|
|
||||||
if not name:
|
|
||||||
die("slugify: missing name")
|
|
||||||
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
|
||||||
if not slug:
|
|
||||||
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
|
||||||
return slug
|
|
||||||
|
|
||||||
|
|
||||||
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
def build_image(ref: str, context: str, *, dockerfile: str = "") -> None:
|
||||||
"""Invokes `docker build` every call. Layer cache makes no-change
|
"""Invokes `docker build` every call. Layer cache makes no-change
|
||||||
rebuilds cheap; running every time means Dockerfile edits land
|
rebuilds cheap; running every time means Dockerfile edits land
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from ..log import die
|
|||||||
from ..manifest import Manifest, ManifestBottle
|
from ..manifest import Manifest, ManifestBottle
|
||||||
from ..supervisor.plan import SupervisePlan
|
from ..supervisor.plan import SupervisePlan
|
||||||
from ..orchestrator.supervisor import Supervisor
|
from ..orchestrator.supervisor import Supervisor
|
||||||
|
from ..util import slugify
|
||||||
from . import BottleSpec
|
from . import BottleSpec
|
||||||
|
|
||||||
|
|
||||||
@@ -44,8 +45,7 @@ def mint_slug(spec: BottleSpec) -> str:
|
|||||||
if spec.identity:
|
if spec.identity:
|
||||||
return spec.identity
|
return spec.identity
|
||||||
if spec.label:
|
if spec.label:
|
||||||
from .docker import util as docker_mod
|
return slugify(spec.label)
|
||||||
return docker_mod.slugify(spec.label)
|
|
||||||
return bottle_identity(spec.agent_name)
|
return bottle_identity(spec.agent_name)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,11 @@ from typing import Callable
|
|||||||
from ...agent_provider import get_provider, runtime_for
|
from ...agent_provider import get_provider, runtime_for
|
||||||
from ...backend import (
|
from ...backend import (
|
||||||
Bottle,
|
Bottle,
|
||||||
|
BottlePlan,
|
||||||
BottleSpec,
|
BottleSpec,
|
||||||
enumerate_active_agents,
|
enumerate_active_agents,
|
||||||
get_bottle_backend,
|
get_bottle_backend,
|
||||||
)
|
)
|
||||||
from ...backend.docker import util as docker_mod
|
|
||||||
from ...backend.docker.bottle_plan import DockerBottlePlan
|
|
||||||
from ...bottle_state import (
|
from ...bottle_state import (
|
||||||
cleanup_state,
|
cleanup_state,
|
||||||
is_preserved,
|
is_preserved,
|
||||||
@@ -40,7 +39,7 @@ from ...image_cache import StaleImageError
|
|||||||
from ...log import info, die
|
from ...log import info, die
|
||||||
from ...manifest import Manifest, ManifestIndex
|
from ...manifest import Manifest, ManifestIndex
|
||||||
from ..constants import PROG
|
from ..constants import PROG
|
||||||
from ...util import read_tty_line
|
from ...util import read_tty_line, slugify
|
||||||
from .. import tui
|
from .. import tui
|
||||||
|
|
||||||
|
|
||||||
@@ -257,10 +256,10 @@ def _uniquify_label_headless(label: str) -> str:
|
|||||||
logging the chosen label. Orchestrators fire-and-forget many bottles,
|
logging the chosen label. Orchestrators fire-and-forget many bottles,
|
||||||
so silently picking a free name beats erroring on every collision."""
|
so silently picking a free name beats erroring on every collision."""
|
||||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||||
if docker_mod.slugify(label) not in active_slugs:
|
if slugify(label) not in active_slugs:
|
||||||
return label
|
return label
|
||||||
n = 2
|
n = 2
|
||||||
while docker_mod.slugify(f"{label}-{n}") in active_slugs:
|
while slugify(f"{label}-{n}") in active_slugs:
|
||||||
n += 1
|
n += 1
|
||||||
chosen = f"{label}-{n}"
|
chosen = f"{label}-{n}"
|
||||||
info(f"label '{label}' already in use; using '{chosen}'")
|
info(f"label '{label}' already in use; using '{chosen}'")
|
||||||
@@ -274,11 +273,11 @@ def prepare_with_preflight(
|
|||||||
spec: BottleSpec,
|
spec: BottleSpec,
|
||||||
*,
|
*,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
render_preflight: Callable[[DockerBottlePlan, str], None],
|
render_preflight: Callable[[BottlePlan, str], None],
|
||||||
prompt_yes: Callable[[], bool],
|
prompt_yes: Callable[[], bool],
|
||||||
dry_run: bool = False,
|
dry_run: bool = False,
|
||||||
backend_name: str | None = None,
|
backend_name: str | None = None,
|
||||||
) -> tuple[DockerBottlePlan | None, str]:
|
) -> tuple[BottlePlan | None, str]:
|
||||||
"""Run `backend.prepare`, render the preflight summary via the
|
"""Run `backend.prepare`, render the preflight summary via the
|
||||||
injected callable, prompt y/N via the injected callable.
|
injected callable, prompt y/N via the injected callable.
|
||||||
|
|
||||||
@@ -405,7 +404,7 @@ def _resolve_unique_label(label: str, color: str) -> tuple[str, str]:
|
|||||||
in use among running bottles. Passes through unchanged when no
|
in use among running bottles. Passes through unchanged when no
|
||||||
collision is found on the first check."""
|
collision is found on the first check."""
|
||||||
while True:
|
while True:
|
||||||
slug_candidate = docker_mod.slugify(label)
|
slug_candidate = slugify(label)
|
||||||
active_slugs = {a.slug for a in enumerate_active_agents()}
|
active_slugs = {a.slug for a in enumerate_active_agents()}
|
||||||
if slug_candidate not in active_slugs:
|
if slug_candidate not in active_slugs:
|
||||||
return label, color
|
return label, color
|
||||||
@@ -432,7 +431,7 @@ def _select_image_policy() -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _text_render_preflight():
|
def _text_render_preflight():
|
||||||
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
|
def _render(plan: BottlePlan, backend_name: str) -> None:
|
||||||
print(file=sys.stderr)
|
print(file=sys.stderr)
|
||||||
print(f"backend: {backend_name}", file=sys.stderr)
|
print(f"backend: {backend_name}", file=sys.stderr)
|
||||||
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
print(_manifest_to_yaml(plan.manifest), file=sys.stderr)
|
||||||
|
|||||||
@@ -26,19 +26,23 @@ from bot_bottle.gateway.egress.addon_core import (
|
|||||||
Config,
|
Config,
|
||||||
Route,
|
Route,
|
||||||
ScanResult,
|
ScanResult,
|
||||||
|
route_to_yaml_dict,
|
||||||
|
)
|
||||||
|
from bot_bottle.gateway.egress.context import resolve_client_context
|
||||||
|
from bot_bottle.gateway.egress.dlp import (
|
||||||
build_inbound_scan_text,
|
build_inbound_scan_text,
|
||||||
build_outbound_scan_text,
|
build_outbound_scan_text,
|
||||||
build_token_allow_payload,
|
build_token_allow_payload,
|
||||||
|
outbound_scan_headers,
|
||||||
|
scan_inbound,
|
||||||
|
scan_outbound,
|
||||||
|
)
|
||||||
|
from bot_bottle.gateway.egress.matching import (
|
||||||
decide,
|
decide,
|
||||||
decide_git_fetch,
|
decide_git_fetch,
|
||||||
is_git_fetch_request,
|
is_git_fetch_request,
|
||||||
is_git_push_request,
|
is_git_push_request,
|
||||||
match_route,
|
match_route,
|
||||||
resolve_client_context,
|
|
||||||
outbound_scan_headers,
|
|
||||||
route_to_yaml_dict,
|
|
||||||
scan_inbound,
|
|
||||||
scan_outbound,
|
|
||||||
)
|
)
|
||||||
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
from bot_bottle.gateway.policy_resolver import PolicyResolveError, PolicyResolver
|
||||||
from bot_bottle.supervisor.types import (
|
from bot_bottle.supervisor.types import (
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
import typing
|
import typing
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
from ...yaml_subset import YamlSubsetError, parse_yaml_subset
|
||||||
|
|
||||||
@@ -30,96 +29,21 @@ from .dlp_config import (
|
|||||||
OUTBOUND_ON_MATCH_VALUES,
|
OUTBOUND_ON_MATCH_VALUES,
|
||||||
parse_inspect_block,
|
parse_inspect_block,
|
||||||
)
|
)
|
||||||
|
from .types import (
|
||||||
|
HEADER_MATCH_TYPES,
|
||||||
# ---------------------------------------------------------------------------
|
LOG_BLOCKS,
|
||||||
# Match types (Gateway API HTTPRoute vocabulary, PRD 0053)
|
LOG_FULL,
|
||||||
# ---------------------------------------------------------------------------
|
LOG_OFF,
|
||||||
|
PATH_MATCH_TYPES,
|
||||||
PATH_MATCH_TYPES = ("exact", "prefix", "regex")
|
VALID_METHODS,
|
||||||
HEADER_MATCH_TYPES = ("exact", "regex")
|
Config,
|
||||||
|
Decision,
|
||||||
VALID_METHODS = frozenset({
|
HeaderMatch,
|
||||||
"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE",
|
MatchEntry,
|
||||||
"CONNECT",
|
PathMatch,
|
||||||
})
|
Route,
|
||||||
|
ScanResult,
|
||||||
|
)
|
||||||
@dataclass(frozen=True)
|
|
||||||
class PathMatch:
|
|
||||||
type: str # "exact" | "prefix" | "regex"
|
|
||||||
value: str
|
|
||||||
compiled: re.Pattern[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class HeaderMatch:
|
|
||||||
name: str
|
|
||||||
value: str
|
|
||||||
type: str = "exact" # "exact" | "regex"
|
|
||||||
compiled: re.Pattern[str] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class MatchEntry:
|
|
||||||
paths: tuple[PathMatch, ...] = ()
|
|
||||||
methods: tuple[str, ...] = ()
|
|
||||||
headers: tuple[HeaderMatch, ...] = ()
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Route:
|
|
||||||
host: str
|
|
||||||
matches: tuple[MatchEntry, ...] = ()
|
|
||||||
auth_scheme: str = ""
|
|
||||||
token_env: str = ""
|
|
||||||
git_fetch: bool = False
|
|
||||||
outbound_detectors: tuple[str, ...] | None = None
|
|
||||||
inbound_detectors: tuple[str, ...] | None = None
|
|
||||||
# "" means unset → DEFAULT_OUTBOUND_ON_MATCH. See OUTBOUND_ON_MATCH_VALUES.
|
|
||||||
outbound_on_match: str = ""
|
|
||||||
preserve_auth: bool = False
|
|
||||||
# False tunnels HTTPS without TLS interception or HTTP-level controls.
|
|
||||||
inspect: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
LOG_OFF = 0 # no logging
|
|
||||||
LOG_BLOCKS = 1 # log block/warn events with request context
|
|
||||||
LOG_FULL = 2 # log block/warn events + full request and response bodies
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Config:
|
|
||||||
routes: tuple[Route, ...]
|
|
||||||
log: int = LOG_OFF
|
|
||||||
# Why this Config is a deny-all, when it is one for a reason *other* than
|
|
||||||
# the bottle's own policy genuinely not listing the host. A deny-all is
|
|
||||||
# indistinguishable from "policy loaded, host not allowed" at the decision
|
|
||||||
# point — both are simply "no matching route" — so without this the
|
|
||||||
# operator sees `host X is not in the allowlist` and goes hunting for a
|
|
||||||
# missing route that was never the problem. Empty for a normally-parsed
|
|
||||||
# policy; `decide` prefers it over the allowlist wording when set.
|
|
||||||
deny_reason: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Decision:
|
|
||||||
action: str # "forward" or "block"
|
|
||||||
reason: str = ""
|
|
||||||
inject_authorization: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ScanResult:
|
|
||||||
severity: str # "block" or "warn"
|
|
||||||
reason: str
|
|
||||||
location: str = "" # where the match was found, e.g. "body", "authorization header"
|
|
||||||
context: str = "" # surrounding text with the match replaced by REDACT
|
|
||||||
# Raw substring the detector matched. Used inside the gateway to key the
|
|
||||||
# supervisor-approved "safe tokens" set (PRD 0062); never logged or written
|
|
||||||
# to a proposal file. Empty for structural detectors (CRLF) that carry no
|
|
||||||
# safelist-able value.
|
|
||||||
matched: str = ""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Fail-closed resolution of a client's policy and egress credentials."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from .types import Config
|
||||||
|
|
||||||
|
|
||||||
|
DENY_UNATTRIBUTED = (
|
||||||
|
"egress: this request was not attributed to any bottle, so no egress policy "
|
||||||
|
"applies and every host is denied. Either the bottle's registry row is "
|
||||||
|
"missing/ambiguous (torn down, or another bottle claimed its source IP), or "
|
||||||
|
"the request carried no matching identity token — check that the caller's "
|
||||||
|
"proxy URL includes it. This is not an allowlist problem."
|
||||||
|
)
|
||||||
|
DENY_UNPARSEABLE = (
|
||||||
|
"egress: this bottle's egress policy could not be parsed, so it is being "
|
||||||
|
"treated as deny-all. Fix the bottle's egress.routes; every host is denied "
|
||||||
|
"until it loads."
|
||||||
|
)
|
||||||
|
DENY_RESOLVER_ERROR = (
|
||||||
|
"egress: the orchestrator could not be reached to resolve this bottle's "
|
||||||
|
"egress policy, so every host is denied (fail-closed). Check that the "
|
||||||
|
"control plane is up; this is not an allowlist problem."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PolicyResolverLike(typing.Protocol):
|
||||||
|
def resolve(self, source_ip: str, identity_token: str = ...) -> str | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class ContextResolverLike(typing.Protocol):
|
||||||
|
def resolve_policy_and_bottle_id(
|
||||||
|
self, source_ip: str, identity_token: str = ...,
|
||||||
|
) -> tuple[str | None, str | None, dict[str, str]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def _config_from_policy(policy: str | None) -> Config:
|
||||||
|
# Local import keeps schema parsing independent of resolver protocols.
|
||||||
|
from .addon_core import load_config
|
||||||
|
if not policy:
|
||||||
|
return Config(routes=(), deny_reason=DENY_UNATTRIBUTED)
|
||||||
|
try:
|
||||||
|
return load_config(policy)
|
||||||
|
except ValueError:
|
||||||
|
return Config(routes=(), deny_reason=DENY_UNPARSEABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_config(
|
||||||
|
resolver: PolicyResolverLike, client_ip: str, identity_token: str = "",
|
||||||
|
) -> Config:
|
||||||
|
try:
|
||||||
|
policy = resolver.resolve(client_ip, identity_token)
|
||||||
|
except Exception: # noqa: BLE001 - a policy lookup failure must deny
|
||||||
|
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR)
|
||||||
|
return _config_from_policy(policy)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_client_context(
|
||||||
|
resolver: ContextResolverLike, client_ip: str, identity_token: str = "",
|
||||||
|
) -> tuple[Config, str, dict[str, str]]:
|
||||||
|
try:
|
||||||
|
policy, bottle_id, tokens = resolver.resolve_policy_and_bottle_id(
|
||||||
|
client_ip, identity_token)
|
||||||
|
except Exception: # noqa: BLE001 - a policy lookup failure must deny
|
||||||
|
return Config(routes=(), deny_reason=DENY_RESOLVER_ERROR), "", {}
|
||||||
|
return _config_from_policy(policy), (bottle_id or ""), tokens
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""DLP scan dispatch and safe proposal rendering for egress requests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from .types import Route, ScanResult
|
||||||
|
|
||||||
|
|
||||||
|
def build_outbound_scan_text(host: str, path: str, query: str,
|
||||||
|
headers: typing.Mapping[str, str], body: str) -> str:
|
||||||
|
parts = [host, path]
|
||||||
|
if query:
|
||||||
|
parts.append(query)
|
||||||
|
parts.extend(f"{name}: {value}" for name, value in headers.items())
|
||||||
|
if body:
|
||||||
|
parts.append(body)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def outbound_scan_headers(route: Route, headers: typing.Mapping[str, str]) -> dict[str, str]:
|
||||||
|
"""Drop agent Authorization when the route injects gateway-owned auth."""
|
||||||
|
skip_auth = bool(route.auth_scheme and route.token_env)
|
||||||
|
return {name: value for name, value in headers.items()
|
||||||
|
if not (skip_auth and name.lower() == "authorization")}
|
||||||
|
|
||||||
|
|
||||||
|
def build_inbound_scan_text(headers: typing.Mapping[str, str], body: str) -> str:
|
||||||
|
parts = [f"{name}: {value}" for name, value in headers.items()]
|
||||||
|
if body:
|
||||||
|
parts.append(body)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _enabled(configured: tuple[str, ...] | None, name: str) -> bool:
|
||||||
|
return configured is None or name in configured
|
||||||
|
|
||||||
|
|
||||||
|
def scan_outbound(route: Route, body: str | bytes, environ: typing.Mapping[str, str], *,
|
||||||
|
safe_tokens: typing.AbstractSet[str] | None = None,
|
||||||
|
crlf_text: str | None = None) -> ScanResult | None:
|
||||||
|
if not route.inspect:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from dlp_detectors import ( # type: ignore[import-not-found]
|
||||||
|
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
|
||||||
|
except ImportError: # pragma: no cover - gateway's flat module path
|
||||||
|
from .dlp_detectors import (
|
||||||
|
scan_crlf_injection, scan_entropy, scan_known_secrets, scan_token_patterns)
|
||||||
|
if isinstance(body, bytes):
|
||||||
|
try:
|
||||||
|
text = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = body.decode("latin-1")
|
||||||
|
else:
|
||||||
|
text = body
|
||||||
|
result = scan_crlf_injection(text if crlf_text is None else crlf_text)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
if _enabled(route.outbound_detectors, "token_patterns"):
|
||||||
|
result = scan_token_patterns(text, location="body", safe_tokens=safe_tokens)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
if _enabled(route.outbound_detectors, "known_secrets"):
|
||||||
|
extra = tuple(prefix for prefix in environ.get(
|
||||||
|
"BOT_BOTTLE_SENSITIVE_PREFIXES", "").split(",") if prefix)
|
||||||
|
result = scan_known_secrets(text, location="body", env=environ,
|
||||||
|
sensitive_prefixes=("EGRESS_TOKEN_",) + extra,
|
||||||
|
safe_tokens=safe_tokens)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
if route.outbound_detectors is not None and "entropy" in route.outbound_detectors:
|
||||||
|
return scan_entropy(text, location="body")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_token_allow_payload(host: str, method: str, path: str, result: ScanResult) -> str:
|
||||||
|
"""Render redacted operator context; the raw matched secret is excluded."""
|
||||||
|
lines = [
|
||||||
|
"egress blocked an outbound request carrying a detected token",
|
||||||
|
f"host: {host}", f"method: {method}", f"path: {path}",
|
||||||
|
f"detector: {result.reason}",
|
||||||
|
]
|
||||||
|
if result.context:
|
||||||
|
lines.append(f"context: {result.context}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def scan_inbound(route: Route, body: str | bytes) -> ScanResult | None:
|
||||||
|
if not route.inspect:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from dlp_detectors import scan_naive_injection # type: ignore[import-not-found]
|
||||||
|
except ImportError: # pragma: no cover - gateway's flat module path
|
||||||
|
from .dlp_detectors import scan_naive_injection
|
||||||
|
text = body if isinstance(body, str) else body.decode("utf-8", errors="replace")
|
||||||
|
if _enabled(route.inbound_detectors, "naive_injection_detection"):
|
||||||
|
return scan_naive_injection(text)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Route matching and request-policy decisions for the egress gateway."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import typing
|
||||||
|
|
||||||
|
from .types import Decision, MatchEntry, PathMatch, Route
|
||||||
|
|
||||||
|
|
||||||
|
def _path_matches(pm: PathMatch, request_path: str) -> bool:
|
||||||
|
if pm.type == "exact":
|
||||||
|
return request_path == pm.value
|
||||||
|
if pm.type == "prefix":
|
||||||
|
if request_path == pm.value:
|
||||||
|
return True
|
||||||
|
if not pm.value.endswith("/"):
|
||||||
|
return request_path.startswith(pm.value + "/")
|
||||||
|
return request_path.startswith(pm.value)
|
||||||
|
return pm.type == "regex" and pm.compiled is not None and pm.compiled.search(request_path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_matches(
|
||||||
|
entry: MatchEntry, request_path: str, request_method: str,
|
||||||
|
request_headers: typing.Mapping[str, str],
|
||||||
|
) -> bool:
|
||||||
|
if entry.paths and not any(_path_matches(pm, request_path) for pm in entry.paths):
|
||||||
|
return False
|
||||||
|
if entry.methods and request_method.upper() not in entry.methods:
|
||||||
|
return False
|
||||||
|
for match in entry.headers:
|
||||||
|
value = request_headers.get(match.name.lower())
|
||||||
|
if value is None:
|
||||||
|
return False
|
||||||
|
if match.type == "exact" and value != match.value:
|
||||||
|
return False
|
||||||
|
if match.type == "regex" and (
|
||||||
|
match.compiled is None or match.compiled.search(value) is None
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_matches(
|
||||||
|
route: Route, request_path: str, request_method: str = "GET",
|
||||||
|
request_headers: typing.Mapping[str, str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether a request satisfies a route's optional match entries."""
|
||||||
|
if not route.matches:
|
||||||
|
return True
|
||||||
|
return any(_entry_matches(entry, request_path, request_method, request_headers or {})
|
||||||
|
for entry in route.matches)
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_push_request(path: str, query: str) -> bool:
|
||||||
|
return path.endswith("/git-receive-pack") or (
|
||||||
|
path.endswith("/info/refs") and any(
|
||||||
|
pair.partition("=") == ("service", "=", "git-receive-pack")
|
||||||
|
for pair in query.split("&")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_git_fetch_request(path: str, query: str) -> bool:
|
||||||
|
return path.endswith("/git-upload-pack") or (
|
||||||
|
path.endswith("/info/refs") and any(
|
||||||
|
pair.partition("=") == ("service", "=", "git-upload-pack")
|
||||||
|
for pair in query.split("&")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def match_route(routes: typing.Sequence[Route], request_host: str) -> Route | None:
|
||||||
|
target = request_host.lower()
|
||||||
|
return next((route for route in routes if route.host.lower() == target), None)
|
||||||
|
|
||||||
|
|
||||||
|
def decide(
|
||||||
|
routes: typing.Sequence[Route], request_host: str, request_path: str,
|
||||||
|
environ: typing.Mapping[str, str], *, request_method: str = "GET",
|
||||||
|
request_headers: typing.Mapping[str, str] | None = None, deny_reason: str = "",
|
||||||
|
) -> Decision:
|
||||||
|
route = match_route(routes, request_host)
|
||||||
|
if route is None:
|
||||||
|
return Decision("block", deny_reason or (
|
||||||
|
f"egress: host {request_host!r} is not in the bottle's egress.routes "
|
||||||
|
"allowlist. Declare a route for it or remove the request."))
|
||||||
|
if not evaluate_matches(route, request_path, request_method, request_headers):
|
||||||
|
return Decision("block", (
|
||||||
|
f"egress: request {request_method} {request_path!r} does not match any "
|
||||||
|
f"entry in matches for {route.host!r}"))
|
||||||
|
if route.auth_scheme and route.token_env:
|
||||||
|
token = environ.get(route.token_env, "")
|
||||||
|
if not token:
|
||||||
|
return Decision("block", (
|
||||||
|
f"egress: route for {route.host!r} declared auth but env var "
|
||||||
|
f"{route.token_env!r} is unset"))
|
||||||
|
return Decision("forward", inject_authorization=f"{route.auth_scheme} {token}")
|
||||||
|
return Decision("forward")
|
||||||
|
|
||||||
|
|
||||||
|
def decide_git_fetch(routes: typing.Sequence[Route], request_host: str) -> Decision:
|
||||||
|
route = match_route(routes, request_host)
|
||||||
|
if route is not None and route.git_fetch:
|
||||||
|
return Decision("forward")
|
||||||
|
return Decision("block", (
|
||||||
|
"egress: git fetch/clone over HTTPS is not allowed by default; use git-gate "
|
||||||
|
"for declared repos or set egress.routes[].git.fetch=true for explicit "
|
||||||
|
"read-only HTTPS Git access."))
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Shared egress policy value objects.
|
||||||
|
|
||||||
|
Kept dependency-free so the schema parser, matcher, DLP scanner, and addon
|
||||||
|
adapter can use the same immutable public shapes without importing each other.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
PATH_MATCH_TYPES = ("exact", "prefix", "regex")
|
||||||
|
HEADER_MATCH_TYPES = ("exact", "regex")
|
||||||
|
VALID_METHODS = frozenset({
|
||||||
|
"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "TRACE",
|
||||||
|
"CONNECT",
|
||||||
|
})
|
||||||
|
|
||||||
|
LOG_OFF = 0
|
||||||
|
LOG_BLOCKS = 1
|
||||||
|
LOG_FULL = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PathMatch:
|
||||||
|
type: str
|
||||||
|
value: str
|
||||||
|
compiled: re.Pattern[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HeaderMatch:
|
||||||
|
name: str
|
||||||
|
value: str
|
||||||
|
type: str = "exact"
|
||||||
|
compiled: re.Pattern[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MatchEntry:
|
||||||
|
paths: tuple[PathMatch, ...] = ()
|
||||||
|
methods: tuple[str, ...] = ()
|
||||||
|
headers: tuple[HeaderMatch, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Route:
|
||||||
|
host: str
|
||||||
|
matches: tuple[MatchEntry, ...] = ()
|
||||||
|
auth_scheme: str = ""
|
||||||
|
token_env: str = ""
|
||||||
|
git_fetch: bool = False
|
||||||
|
outbound_detectors: tuple[str, ...] | None = None
|
||||||
|
inbound_detectors: tuple[str, ...] | None = None
|
||||||
|
outbound_on_match: str = ""
|
||||||
|
preserve_auth: bool = False
|
||||||
|
inspect: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
routes: tuple[Route, ...]
|
||||||
|
log: int = LOG_OFF
|
||||||
|
deny_reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Decision:
|
||||||
|
action: str
|
||||||
|
reason: str = ""
|
||||||
|
inject_authorization: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScanResult:
|
||||||
|
severity: str
|
||||||
|
reason: str
|
||||||
|
location: str = ""
|
||||||
|
context: str = ""
|
||||||
|
matched: str = ""
|
||||||
@@ -57,6 +57,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import http.server
|
import http.server
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import socketserver
|
import socketserver
|
||||||
import sys
|
import sys
|
||||||
@@ -217,13 +218,18 @@ def dispatch( # pylint: disable=too-many-return-statements,too-many-branches
|
|||||||
raw_ips = data.get("live_source_ips")
|
raw_ips = data.get("live_source_ips")
|
||||||
if not isinstance(raw_ips, list):
|
if not isinstance(raw_ips, list):
|
||||||
return 400, {"error": "live_source_ips (list of strings) is required"}
|
return 400, {"error": "live_source_ips (list of strings) is required"}
|
||||||
live = [ip for ip in raw_ips if isinstance(ip, str) and ip]
|
if any(not isinstance(ip, str) or not ip for ip in raw_ips):
|
||||||
|
return 400, {"error": "live_source_ips must contain non-empty strings"}
|
||||||
|
live = raw_ips
|
||||||
grace = data.get("grace_seconds")
|
grace = data.get("grace_seconds")
|
||||||
kwargs = (
|
kwargs: dict[str, float] = {}
|
||||||
{"grace_seconds": float(grace)}
|
if grace is not None:
|
||||||
if isinstance(grace, (int, float)) and not isinstance(grace, bool)
|
if isinstance(grace, bool) or not isinstance(grace, (int, float)):
|
||||||
else {}
|
return 400, {"error": "grace_seconds must be a non-negative finite number"}
|
||||||
)
|
parsed_grace = float(grace)
|
||||||
|
if not math.isfinite(parsed_grace) or parsed_grace < 0:
|
||||||
|
return 400, {"error": "grace_seconds must be a non-negative finite number"}
|
||||||
|
kwargs["grace_seconds"] = parsed_grace
|
||||||
return 200, {"reaped": orch.reconcile(live, **kwargs)}
|
return 200, {"reaped": orch.reconcile(live, **kwargs)}
|
||||||
|
|
||||||
if method == "POST" and route == "/attribute":
|
if method == "POST" and route == "/attribute":
|
||||||
|
|||||||
@@ -9,8 +9,11 @@ import difflib
|
|||||||
import hashlib
|
import hashlib
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
from .log import die
|
||||||
|
|
||||||
|
|
||||||
def sha256_hex(content: str) -> str:
|
def sha256_hex(content: str) -> str:
|
||||||
"""Hex SHA-256 of a UTF-8 string."""
|
"""Hex SHA-256 of a UTF-8 string."""
|
||||||
@@ -67,3 +70,20 @@ def expand_tilde(path: str) -> str:
|
|||||||
home = os.environ.get("HOME", "")
|
home = os.environ.get("HOME", "")
|
||||||
return home + path[1:]
|
return home + path[1:]
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
_SLUG_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(name: str) -> str:
|
||||||
|
"""Return a portable bottle identifier from a human-readable name.
|
||||||
|
|
||||||
|
This is deliberately a root utility: names are part of the generic CLI
|
||||||
|
and state model, not a Docker container concern.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
die("slugify: missing name")
|
||||||
|
slug = _SLUG_RE.sub("-", name.lower()).strip("-")
|
||||||
|
if not slug:
|
||||||
|
die(f"name '{name}' produced an empty slug; use alphanumeric characters")
|
||||||
|
return slug
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Architecture rules that should fail before coupling becomes entrenched."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCliBackendBoundaries(unittest.TestCase):
|
||||||
|
def test_cli_does_not_import_a_concrete_backend(self) -> None:
|
||||||
|
forbidden = (
|
||||||
|
"backend.docker", "backend.firecracker", "backend.macos_container",
|
||||||
|
"bot_bottle.backend.docker", "bot_bottle.backend.firecracker",
|
||||||
|
"bot_bottle.backend.macos_container",
|
||||||
|
)
|
||||||
|
violations: list[str] = []
|
||||||
|
for path in (ROOT / "bot_bottle" / "cli").rglob("*.py"):
|
||||||
|
tree = ast.parse(path.read_text(), filename=str(path))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
module = node.module if isinstance(node, ast.ImportFrom) else None
|
||||||
|
if module and module.startswith(forbidden):
|
||||||
|
violations.append(f"{path.relative_to(ROOT)}:{node.lineno}: {module}")
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
violations.extend(
|
||||||
|
f"{path.relative_to(ROOT)}:{node.lineno}: {alias.name}"
|
||||||
|
for alias in node.names if alias.name.startswith(forbidden)
|
||||||
|
)
|
||||||
|
self.assertEqual([], violations, "generic CLI imports concrete backend internals:\n" +
|
||||||
|
"\n".join(violations))
|
||||||
|
|
||||||
|
|
||||||
|
class TestRuntimeModuleSizes(unittest.TestCase):
|
||||||
|
def test_egress_modules_stay_focused(self) -> None:
|
||||||
|
caps = {
|
||||||
|
"addon_core.py": 900, # compatibility + policy-schema parsing only
|
||||||
|
"types.py": 180,
|
||||||
|
"matching.py": 180,
|
||||||
|
"dlp.py": 180,
|
||||||
|
"context.py": 140,
|
||||||
|
}
|
||||||
|
directory = ROOT / "bot_bottle" / "gateway" / "egress"
|
||||||
|
oversized = [f"{name} ({len((directory / name).read_text().splitlines())}>{cap})"
|
||||||
|
for name, cap in caps.items()
|
||||||
|
if len((directory / name).read_text().splitlines()) > cap]
|
||||||
|
self.assertEqual([], oversized, "split a module rather than raising its cap: " +
|
||||||
|
", ".join(oversized))
|
||||||
@@ -647,10 +647,24 @@ class TestReconcileRoute(unittest.TestCase):
|
|||||||
self.assertEqual(200, status)
|
self.assertEqual(200, status)
|
||||||
self.assertEqual([], payload["reaped"])
|
self.assertEqual([], payload["reaped"])
|
||||||
|
|
||||||
def test_non_string_entries_are_ignored(self) -> None:
|
def test_non_string_entries_are_rejected(self) -> None:
|
||||||
dead = self._old("10.0.0.4")
|
|
||||||
status, payload = dispatch(
|
status, payload = dispatch(
|
||||||
self.orch, "POST", "/reconcile",
|
self.orch, "POST", "/reconcile",
|
||||||
_body({"live_source_ips": [None, 7, "10.0.0.9"]}))
|
_body({"live_source_ips": [None, 7, "10.0.0.9"]}))
|
||||||
self.assertEqual(200, status)
|
self.assertEqual(400, status)
|
||||||
self.assertEqual([dead], payload["reaped"])
|
self.assertIn("live_source_ips", payload["error"])
|
||||||
|
|
||||||
|
def test_empty_live_source_ip_is_rejected(self) -> None:
|
||||||
|
status, payload = dispatch(
|
||||||
|
self.orch, "POST", "/reconcile", _body({"live_source_ips": [""]}))
|
||||||
|
self.assertEqual(400, status)
|
||||||
|
self.assertIn("live_source_ips", payload["error"])
|
||||||
|
|
||||||
|
def test_invalid_grace_seconds_is_rejected(self) -> None:
|
||||||
|
for value in (True, "30", -1, float("inf"), float("nan")):
|
||||||
|
with self.subTest(value=value):
|
||||||
|
status, payload = dispatch(
|
||||||
|
self.orch, "POST", "/reconcile",
|
||||||
|
_body({"live_source_ips": [], "grace_seconds": value}))
|
||||||
|
self.assertEqual(400, status)
|
||||||
|
self.assertIn("grace_seconds", payload["error"])
|
||||||
|
|||||||
Reference in New Issue
Block a user