Files
deusdata--codebase-memory-mcp/scripts/security-network-source-audit.py
2026-08-08 18:13:36 +02:00

444 lines
16 KiB
Python

#!/usr/bin/env python3
"""Fail-closed source audit for raw C networking calls.
This is intentionally a small lexical scanner rather than a grep expression:
comments and string/character literals cannot satisfy an allowance, calls at
column one or with whitespace before ``(`` are still counted, and multiple
calls on one line remain distinct. It is a review tripwire, not a substitute
for the runtime network and CodeQL gates.
"""
from __future__ import annotations
import dataclasses
import pathlib
import re
import sys
from typing import Sequence
RAW_FUNCTIONS = ("socket", "connect", "sendto")
CALL_RE = re.compile(
r"(?<![A-Za-z0-9_])(?P<name>" + "|".join(RAW_FUNCTIONS) + r")[\t\r\n ]*\("
)
@dataclasses.dataclass(frozen=True)
class Call:
name: str
line: int
start: int
end: int
text: str
@dataclasses.dataclass(frozen=True)
class FunctionRange:
name: str
body_start: int
body_end: int
@dataclasses.dataclass(frozen=True)
class Allowance:
file: str
function: str
expected: int
transport: str
scope: str
justification: str
def _mask_non_code(source: str) -> str:
"""Replace C comments and literals with spaces while preserving offsets."""
chars = list(source)
index = 0
state = "code"
quote = ""
while index < len(chars):
current = chars[index]
following = chars[index + 1] if index + 1 < len(chars) else ""
if state == "code":
if current == "/" and following == "/":
chars[index] = chars[index + 1] = " "
index += 2
state = "line-comment"
continue
if current == "/" and following == "*":
chars[index] = chars[index + 1] = " "
index += 2
state = "block-comment"
continue
if current in ('"', "'"):
quote = current
chars[index] = " "
index += 1
state = "literal"
continue
index += 1
continue
if state == "line-comment":
if current == "\n":
state = "code"
else:
chars[index] = " "
index += 1
continue
if state == "block-comment":
if current == "*" and following == "/":
chars[index] = chars[index + 1] = " "
index += 2
state = "code"
else:
if current != "\n":
chars[index] = " "
index += 1
continue
if state == "literal":
if current == "\\" and following:
chars[index] = " "
if following != "\n":
chars[index + 1] = " "
index += 2
continue
if current == quote:
chars[index] = " "
index += 1
state = "code"
continue
if current != "\n":
chars[index] = " "
index += 1
return "".join(chars)
def _scan_calls(masked: str) -> list[Call]:
calls: list[Call] = []
for match in CALL_RE.finditer(masked):
opening = match.end() - 1
depth = 0
closing = len(masked)
for cursor in range(opening, len(masked)):
if masked[cursor] == "(":
depth += 1
elif masked[cursor] == ")":
depth -= 1
if depth == 0:
closing = cursor + 1
break
raw = masked[match.start("name") : closing]
calls.append(
Call(
name=match.group("name"),
line=masked.count("\n", 0, match.start("name")) + 1,
start=match.start("name"),
end=closing,
text=re.sub(r"\s+", " ", raw).strip(),
)
)
return calls
def _name_before_body(masked: str, opening: int) -> str | None:
prefix = masked[max(0, opening - 4096) : opening]
match = re.search(r"([A-Za-z_][A-Za-z0-9_]*)\s*\([^;{}]*\)\s*$", prefix, re.DOTALL)
return match.group(1) if match else None
def _function_ranges(masked: str) -> list[FunctionRange]:
ranges: list[FunctionRange] = []
depth = 0
opening = -1
function_name: str | None = None
for index, character in enumerate(masked):
if character == "{":
if depth == 0:
opening = index
function_name = _name_before_body(masked, index)
depth += 1
elif character == "}" and depth > 0:
depth -= 1
if depth == 0 and function_name is not None:
ranges.append(FunctionRange(function_name, opening, index + 1))
opening = -1
function_name = None
return ranges
def _scope_for(call: Call, ranges: Sequence[FunctionRange]) -> FunctionRange | None:
for scope in ranges:
if scope.body_start < call.start < scope.body_end:
return scope
return None
def _fullmatch(pattern: str, call: Call) -> bool:
return re.fullmatch(pattern, call.text) is not None
SOCKET_UNIX = r"socket\s*\(\s*AF_UNIX\s*,\s*SOCK_STREAM\s*,\s*0\s*\)"
SOCKET_LOOPBACK = r"socket\s*\(\s*AF_INET\s*,\s*SOCK_STREAM\s*,\s*0\s*\)"
CONNECT_UNIX = (
r"connect\s*\(\s*fd\s*,\s*\(\s*const\s+struct\s+sockaddr\s*\*\s*\)\s*"
r"address\s*,\s*address_length\s*\)"
)
CONNECT_LOOPBACK = (
r"connect\s*\(\s*socket_handle\s*,\s*\(\s*const\s+struct\s+sockaddr\s*\*\s*\)"
r"\s*&\s*address\s*,\s*sizeof\s*\(\s*address\s*\)\s*\)"
)
def _loopback_connect_witness(masked: str, call: Call, scope: FunctionRange) -> bool:
body = masked[scope.body_start + 1 : scope.body_end - 1]
call_offset = call.start - scope.body_start - 1
# The complete witness must be one unconditional, contiguous statement
# sequence in the same helper. Merely finding the three assignments in
# order is insufficient: `if (false) address.sin_family = ...` must not
# satisfy a local-only guarantee.
witness = re.compile(
r"\bstruct\s+sockaddr_in\s+address\s*;\s*"
r"memset\s*\(\s*&\s*address\s*,\s*0\s*,\s*sizeof\s*\(\s*address\s*\)\s*\)\s*;\s*"
r"address\s*\.\s*sin_family\s*=\s*AF_INET\s*;\s*"
r"address\s*\.\s*sin_port\s*=\s*htons\s*\(\s*\(\s*unsigned\s+short\s*\)"
r"\s*port\s*\)\s*;\s*"
r"address\s*\.\s*sin_addr\s*\.\s*s_addr\s*=\s*htonl\s*\(\s*0x7F000001U\s*\)\s*;\s*"
r"if\s*\(\s*$",
re.DOTALL,
)
return witness.search(body[:call_offset]) is not None
def _semantic_ok(masked: str, calls: Sequence[Call], allowance: Allowance) -> bool:
ranges = _function_ranges(masked)
scopes = [_scope_for(call, ranges) for call in calls]
if any(scope is None or scope.name != allowance.scope for scope in scopes):
return False
if allowance.transport == "unix" and allowance.function == "socket":
return all(_fullmatch(SOCKET_UNIX, call) for call in calls)
if allowance.transport == "unix" and allowance.function == "connect":
if not all(_fullmatch(CONNECT_UNIX, call) for call in calls):
return False
scope = scopes[0] if scopes else None
if scope is None:
return False
header = masked[max(0, scope.body_start - 512) : scope.body_start]
return (
re.search(r"const\s+struct\s+sockaddr_un\s*\*\s*address", header) is not None
)
if allowance.transport == "loopback-v4" and allowance.function == "socket":
return all(_fullmatch(SOCKET_LOOPBACK, call) for call in calls)
if allowance.transport == "loopback-v4" and allowance.function == "connect":
if len(calls) != 1 or not _fullmatch(CONNECT_LOOPBACK, calls[0]):
return False
scope = scopes[0] if scopes else None
return scope is not None and _loopback_connect_witness(masked, calls[0], scope)
return False
def _read_allowances(path: pathlib.Path) -> tuple[list[Allowance], list[str]]:
allowances: list[Allowance] = []
errors: list[str] = []
for number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if not raw_line.startswith("NETWORK:"):
continue
fields = raw_line.split(":", 6)
if len(fields) != 7:
errors.append(f"{path}:{number}: malformed NETWORK allowance")
continue
_, file_name, function, expected_text, transport, scope, justification = fields
try:
expected = int(expected_text)
except ValueError:
errors.append(f"{path}:{number}: invalid expected count {expected_text!r}")
continue
if expected <= 0 or function not in RAW_FUNCTIONS or not scope or not justification:
errors.append(f"{path}:{number}: incomplete NETWORK allowance")
continue
allowances.append(
Allowance(file_name, function, expected, transport, scope, justification)
)
return allowances, errors
def _audit(repo_root: pathlib.Path, allowlist_path: pathlib.Path) -> int:
allowances, errors = _read_allowances(allowlist_path)
by_key: dict[tuple[str, str], list[Allowance]] = {}
for allowance in allowances:
by_key.setdefault((allowance.file, allowance.function), []).append(allowance)
consumed: set[tuple[str, str]] = set()
for source_path in sorted((repo_root / "src").rglob("*.c")):
relative = source_path.relative_to(repo_root).as_posix()
if relative == "src/ui/httpd.c":
continue
masked = _mask_non_code(source_path.read_text(encoding="utf-8", errors="replace"))
all_calls = _scan_calls(masked)
for function in RAW_FUNCTIONS:
calls = [call for call in all_calls if call.name == function]
if not calls:
continue
key = (relative, function)
entries = by_key.get(key, [])
consumed.add(key)
if len(entries) != 1:
print(f"BLOCKED: {relative}: unexpected raw {function}() surface")
print(f" expected=none actual={len(calls)} transport=none semantic_ok=false")
for call in calls:
print(f" {call.line}: {call.text}")
errors.append(f"{relative}:{function}: allowance count is {len(entries)}")
continue
allowance = entries[0]
semantic_ok = _semantic_ok(masked, calls, allowance)
if len(calls) != allowance.expected or not semantic_ok:
print(f"BLOCKED: {relative}: unexpected raw {function}() surface")
print(
f" expected={allowance.expected} actual={len(calls)} "
f"transport={allowance.transport} scope={allowance.scope} "
f"semantic_ok={str(semantic_ok).lower()}"
)
for call in calls:
print(f" {call.line}: {call.text}")
errors.append(f"{relative}:{function}: reviewed shape changed")
else:
print(
f"REVIEWED: {relative}: {len(calls)} count-bounded "
f"{allowance.transport} {function}() call(s) in {allowance.scope}"
)
for key, entries in sorted(by_key.items()):
if key not in consumed:
errors.append(f"{key[0]}:{key[1]}: stale NETWORK allowance ({len(entries)} entry)")
for error in errors:
print(f" policy-error: {error}", file=sys.stderr)
return 1 if errors else 0
class SelfTestFailure(RuntimeError):
pass
def _require(condition: bool, message: str) -> None:
if not condition:
raise SelfTestFailure(message)
def _self_test() -> None:
scanner_fixture = r'''
// socket(AF_INET, SOCK_STREAM, 0); connect(fd, bad, 1);
const char *decoy = "socket(AF_INET, SOCK_STREAM, 0)";
static void calls(void) {
socket (AF_INET6, SOCK_STREAM, 0); socket(AF_INET, SOCK_STREAM, 0);
/* connect(fd, bad, 1); */ connect (fd, bad, 1);
}
'''
scanned = _scan_calls(_mask_non_code(scanner_fixture))
_require(
[call.name for call in scanned] == ["socket", "socket", "connect"],
"comments, literals, whitespace, or same-line calls were counted incorrectly",
)
_require([call.line for call in scanned] == [5, 5, 6], "call line mapping changed")
safe = r'''
static bool loopback_probe(main_daemon_ctl_socket_t socket_handle, int port) {
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_port = htons((unsigned short)port);
address.sin_addr.s_addr = htonl(0x7F000001U);
if (connect (socket_handle, (const struct sockaddr *)&address, sizeof(address)) == 0) {
return true;
}
return false;
}
static bool endpoint(void) {
return socket (AF_INET, SOCK_STREAM, 0) >= 0;
}
'''
masked = _mask_non_code(safe)
calls = _scan_calls(masked)
connect_allowance = Allowance(
"src/main.c", "connect", 1, "loopback-v4", "loopback_probe", "self-test"
)
socket_allowance = Allowance(
"src/main.c", "socket", 1, "loopback-v4", "endpoint", "self-test"
)
_require(
_semantic_ok(masked, [call for call in calls if call.name == "connect"], connect_allowance),
"safe loopback connect witness was rejected",
)
_require(
_semantic_ok(masked, [call for call in calls if call.name == "socket"], socket_allowance),
"safe loopback socket witness was rejected",
)
non_loopback = safe.replace("0x7F000001U", "0x08080808U")
masked_bad = _mask_non_code(non_loopback)
_require(
not _semantic_ok(
masked_bad,
[call for call in _scan_calls(masked_bad) if call.name == "connect"],
connect_allowance,
),
"non-loopback address was accepted",
)
reassigned = safe.replace(
" if (connect",
" address.sin_addr.s_addr = htonl(0x08080808U);\n if (connect",
)
masked_bad = _mask_non_code(reassigned)
_require(
not _semantic_ok(
masked_bad,
[call for call in _scan_calls(masked_bad) if call.name == "connect"],
connect_allowance,
),
"post-witness address reassignment was accepted",
)
conditional = safe.replace(
" address.sin_family = AF_INET;\n"
" address.sin_port = htons((unsigned short)port);\n"
" address.sin_addr.s_addr = htonl(0x7F000001U);",
" if (false) address.sin_family = AF_INET;\n"
" if (false) address.sin_port = htons((unsigned short)port);\n"
" if (false) address.sin_addr.s_addr = htonl(0x7F000001U);",
)
masked_bad = _mask_non_code(conditional)
_require(
not _semantic_ok(
masked_bad,
[call for call in _scan_calls(masked_bad) if call.name == "connect"],
connect_allowance,
),
"conditional loopback assignments were accepted",
)
wrong_family = safe.replace("socket (AF_INET, SOCK_STREAM, 0)",
"/* socket(AF_INET, SOCK_STREAM, 0) */\n"
" socket (AF_INET6, SOCK_STREAM, 0)")
masked_bad = _mask_non_code(wrong_family)
_require(
not _semantic_ok(
masked_bad,
[call for call in _scan_calls(masked_bad) if call.name == "socket"],
socket_allowance,
),
"wrong socket family plus comment decoy was accepted",
)
def main(argv: Sequence[str]) -> int:
try:
_self_test()
except SelfTestFailure as error:
print(f"BLOCKED: network source audit self-test failed: {error}", file=sys.stderr)
return 1
if len(argv) == 2 and argv[1] == "--self-test":
print("network source audit self-test: OK")
return 0
if len(argv) != 3:
print(f"usage: {argv[0]} REPO_ROOT ALLOWLIST", file=sys.stderr)
return 2
return _audit(pathlib.Path(argv[1]).resolve(), pathlib.Path(argv[2]).resolve())
if __name__ == "__main__":
raise SystemExit(main(sys.argv))