feat: harden community production foundation through story 1.5
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
|
||||
|
||||
DEFAULT_EXCLUDED_PATHS = {
|
||||
@@ -26,7 +28,14 @@ ALLOW_DIRECTIVE = re.compile(
|
||||
)
|
||||
|
||||
FORBIDDEN_PATTERNS = [
|
||||
("enterprise", re.compile(r"\benterprise\b", re.IGNORECASE)),
|
||||
(
|
||||
"multi-workspace",
|
||||
re.compile(r"\bmulti[-_ ]workspace\b", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"enterprise",
|
||||
re.compile(r"\benterprise\b|\benterprise_rbac\b", re.IGNORECASE),
|
||||
),
|
||||
("cloud", re.compile(r"\bcloud\b", re.IGNORECASE)),
|
||||
("commercial", re.compile(r"\bcommercial\b|коммер", re.IGNORECASE)),
|
||||
("cloud-russian", re.compile(r"облач", re.IGNORECASE)),
|
||||
@@ -47,8 +56,26 @@ FORBIDDEN_PATTERNS = [
|
||||
("machine-token", re.compile(r"machine token", re.IGNORECASE)),
|
||||
("token-issuer", re.compile(r"token issuer", re.IGNORECASE)),
|
||||
("request-variables", re.compile(r"request\.variables", re.IGNORECASE)),
|
||||
(
|
||||
"non-rest-upstream",
|
||||
re.compile(r"\bnon[-_ ]rest[-_ ]upstream\b", re.IGNORECASE),
|
||||
),
|
||||
(
|
||||
"distributed-load-targets",
|
||||
re.compile(r"\barbitrary[-_ ]distributed[-_ ]load[-_ ]targets\b", re.IGNORECASE),
|
||||
),
|
||||
]
|
||||
|
||||
MAX_PATH_LENGTH = 1024
|
||||
MAX_FINDINGS = 1000
|
||||
MAX_REPORT_BYTES = 65_536
|
||||
BINARY_PROBE_BYTES = 4096
|
||||
MAX_LINE_CHARACTERS = 65_536
|
||||
MAX_TEXT_FILE_BYTES = 16 * 1024 * 1024
|
||||
MAX_TOTAL_TEXT_BYTES = 256 * 1024 * 1024
|
||||
MAX_FILES = 100_000
|
||||
MAX_FILE_LIST_BYTES = 16 * 1024 * 1024
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -69,15 +96,33 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
def git_tracked_files(root: Path) -> list[str]:
|
||||
result = subprocess.run(
|
||||
["git", "ls-files"],
|
||||
process = subprocess.Popen(
|
||||
["git", "ls-files", "--cached", "-z"],
|
||||
cwd=root,
|
||||
check=True,
|
||||
text=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
return [line for line in result.stdout.splitlines() if line]
|
||||
if process.stdout is None:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise OSError("git file discovery has no output stream")
|
||||
output = process.stdout.read(MAX_FILE_LIST_BYTES + 1)
|
||||
if len(output) > MAX_FILE_LIST_BYTES:
|
||||
process.kill()
|
||||
process.wait()
|
||||
raise ValueError("tracked file list exceeds limit")
|
||||
return_code = process.wait()
|
||||
if return_code != 0:
|
||||
raise subprocess.CalledProcessError(return_code, process.args)
|
||||
discovered = [path.decode("utf-8") for path in output.split(b"\0") if path]
|
||||
# A tracked deletion has no content to inspect and is a valid worktree state.
|
||||
# Existing and broken symlink entries remain in the list so path validation
|
||||
# can reject them fail-closed.
|
||||
return [
|
||||
path
|
||||
for path in discovered
|
||||
if (root / path).exists() or (root / path).is_symlink()
|
||||
]
|
||||
|
||||
|
||||
def should_skip_path(path: str) -> bool:
|
||||
@@ -87,54 +132,173 @@ def should_skip_path(path: str) -> bool:
|
||||
|
||||
|
||||
def is_binary(data: bytes) -> bool:
|
||||
return b"\0" in data[:4096]
|
||||
return b"\0" in data[:BINARY_PROBE_BYTES]
|
||||
|
||||
|
||||
def scan_file(root: Path, relative_path: str) -> list[str]:
|
||||
def path_has_symlink(root: Path, logical: PurePosixPath) -> bool:
|
||||
current = root
|
||||
for part in logical.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def resolve_scannable_file(root: Path, relative_path: str) -> Path | None:
|
||||
if (
|
||||
not relative_path
|
||||
or len(relative_path) > MAX_PATH_LENGTH
|
||||
or "\\" in relative_path
|
||||
or any(ord(character) < 32 or ord(character) == 127 for character in relative_path)
|
||||
):
|
||||
return None
|
||||
logical = PurePosixPath(relative_path)
|
||||
if logical.is_absolute() or any(part in {"", ".", ".."} for part in logical.parts):
|
||||
return None
|
||||
if str(logical) != relative_path or path_has_symlink(root, logical):
|
||||
return None
|
||||
candidate = root.joinpath(*logical.parts)
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
except (FileNotFoundError, OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
return resolved if resolved.is_file() else None
|
||||
|
||||
|
||||
def normalized_identifier_text(line: str) -> str:
|
||||
with_word_boundaries = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "_", line)
|
||||
return re.sub(r"(?<=[A-Z])(?=[A-Z][a-z])", "_", with_word_boundaries)
|
||||
|
||||
|
||||
def scan_file(
|
||||
root: Path,
|
||||
relative_path: str,
|
||||
remaining_text_bytes: int = MAX_TOTAL_TEXT_BYTES,
|
||||
) -> tuple[list[str], bool, bool, int]:
|
||||
path = resolve_scannable_file(root, relative_path)
|
||||
if path is None:
|
||||
return [], False, False, 0
|
||||
|
||||
if should_skip_path(relative_path):
|
||||
return []
|
||||
return [], True, False, 0
|
||||
|
||||
path = root / relative_path
|
||||
if not path.is_file():
|
||||
return []
|
||||
|
||||
data = path.read_bytes()
|
||||
if is_binary(data):
|
||||
return []
|
||||
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
findings: list[str] = []
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
directive = ALLOW_DIRECTIVE.search(line)
|
||||
allowed_markers = (
|
||||
{label.lower() for label in directive.group(1).split(",")}
|
||||
if directive
|
||||
else set()
|
||||
)
|
||||
for label, pattern in FORBIDDEN_PATTERNS:
|
||||
if label in allowed_markers:
|
||||
continue
|
||||
if pattern.search(line):
|
||||
findings.append(f"{relative_path}:{line_no}: forbidden marker `{label}`")
|
||||
return findings
|
||||
scanned_bytes = 0
|
||||
try:
|
||||
with path.open("rb") as binary_file:
|
||||
if is_binary(binary_file.read(BINARY_PROBE_BYTES)):
|
||||
return [], True, False, 0
|
||||
file_size = os.fstat(binary_file.fileno()).st_size
|
||||
if file_size > MAX_TEXT_FILE_BYTES or file_size > remaining_text_bytes:
|
||||
return [], False, False, 0
|
||||
binary_file.seek(0)
|
||||
with io.TextIOWrapper(binary_file, encoding="utf-8", errors="strict") as text_file:
|
||||
line_no = 0
|
||||
while True:
|
||||
line = text_file.readline(MAX_LINE_CHARACTERS + 1)
|
||||
if not line:
|
||||
break
|
||||
line_no += 1
|
||||
if len(line) > MAX_LINE_CHARACTERS:
|
||||
return findings, False, False, scanned_bytes
|
||||
if len(line) == MAX_LINE_CHARACTERS and not line.endswith("\n"):
|
||||
if text_file.read(1):
|
||||
return findings, False, False, scanned_bytes
|
||||
|
||||
scanned_bytes += len(line.encode("utf-8"))
|
||||
if (
|
||||
scanned_bytes > MAX_TEXT_FILE_BYTES
|
||||
or scanned_bytes > remaining_text_bytes
|
||||
):
|
||||
return findings, False, False, scanned_bytes
|
||||
|
||||
directive = ALLOW_DIRECTIVE.search(line)
|
||||
allowed_markers = (
|
||||
{label.lower() for label in directive.group(1).split(",")}
|
||||
if directive
|
||||
else set()
|
||||
)
|
||||
normalized_line = normalized_identifier_text(line)
|
||||
for label, pattern in FORBIDDEN_PATTERNS:
|
||||
if label in allowed_markers:
|
||||
continue
|
||||
if pattern.search(line) or pattern.search(normalized_line):
|
||||
if len(findings) >= MAX_FINDINGS:
|
||||
return findings, True, True, scanned_bytes
|
||||
findings.append(
|
||||
f"{relative_path}:{line_no}: forbidden marker `{label}`"
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return findings, False, False, scanned_bytes
|
||||
return findings, True, False, scanned_bytes
|
||||
|
||||
|
||||
def render_failure(findings: list[str], invalid_count: int, input_truncated: bool) -> str:
|
||||
lines = ["Community scope check failed:"]
|
||||
selected_findings = findings[:MAX_FINDINGS]
|
||||
remaining = MAX_FINDINGS - len(selected_findings)
|
||||
selected_invalid = min(invalid_count, remaining)
|
||||
lines.extend(f"error: {finding}" for finding in selected_findings)
|
||||
lines.extend("error: invalid explicit file path" for _ in range(selected_invalid))
|
||||
if len(findings) + invalid_count > MAX_FINDINGS or input_truncated:
|
||||
lines.append("error: findings truncated")
|
||||
marker = "error: report truncated\n"
|
||||
report = "\n".join(lines) + "\n"
|
||||
if len(report.encode("utf-8")) <= MAX_REPORT_BYTES:
|
||||
return report
|
||||
budget = MAX_REPORT_BYTES - len(marker.encode("utf-8"))
|
||||
kept: list[str] = []
|
||||
used = 0
|
||||
for line in lines:
|
||||
encoded = (line + "\n").encode("utf-8")
|
||||
if used + len(encoded) > budget:
|
||||
break
|
||||
kept.append(line)
|
||||
used += len(encoded)
|
||||
return "\n".join(kept) + ("\n" if kept else "") + marker
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
root = args.root.resolve()
|
||||
files = args.files if args.files is not None else git_tracked_files(root)
|
||||
try:
|
||||
files = args.files if args.files is not None else git_tracked_files(root)
|
||||
except (OSError, subprocess.SubprocessError, UnicodeDecodeError, ValueError):
|
||||
sys.stderr.write(render_failure([], 1, True))
|
||||
return 1
|
||||
findings: list[str] = []
|
||||
invalid_count = 0
|
||||
input_truncated = False
|
||||
scanned_text_bytes = 0
|
||||
|
||||
for relative_path in sorted(files):
|
||||
findings.extend(scan_file(root, relative_path))
|
||||
|
||||
if findings:
|
||||
print("Community scope check failed:", file=sys.stderr)
|
||||
for finding in findings:
|
||||
print(f"error: {finding}", file=sys.stderr)
|
||||
if len(files) > MAX_FILES:
|
||||
sys.stderr.write(render_failure([], 1, True))
|
||||
return 1
|
||||
|
||||
print(f"Community scope check passed ({len(files)} tracked files scanned)")
|
||||
sorted_files = sorted(files)
|
||||
for index, relative_path in enumerate(sorted_files):
|
||||
file_findings, valid, file_truncated, file_bytes = scan_file(
|
||||
root,
|
||||
relative_path,
|
||||
MAX_TOTAL_TEXT_BYTES - scanned_text_bytes,
|
||||
)
|
||||
scanned_text_bytes += file_bytes
|
||||
if not valid:
|
||||
invalid_count += 1
|
||||
findings.extend(file_findings)
|
||||
if file_truncated:
|
||||
input_truncated = True
|
||||
break
|
||||
if len(findings) + invalid_count >= MAX_FINDINGS:
|
||||
input_truncated = index + 1 < len(sorted_files)
|
||||
break
|
||||
|
||||
if findings or invalid_count:
|
||||
sys.stderr.write(render_failure(findings, invalid_count, input_truncated))
|
||||
return 1
|
||||
|
||||
print(f"Community scope check passed ({len(files)} files scanned)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user