-
Notifications
You must be signed in to change notification settings - Fork 721
feat(plugin): add checked SECURITY.md inputs #564
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 8 commits
a4fe371
6229e14
2488778
e0191aa
5c70219
687e943
d91b929
d9e69cf
30a9b3f
d8f01c0
4c5bbf3
119439b
61bfbdf
5bea2ee
02ba96c
bdc337f
8e2a444
8b5a16a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,11 +17,22 @@ class ResolutionError(ValueError): | |
| """Raised when a SECURITY.md chain cannot be resolved.""" | ||
|
|
||
|
|
||
| def _relative_to(path: Path, root: Path) -> Path | None: | ||
| """Use filesystem identity, including case-sensitive Windows directories.""" | ||
| for ancestor in (path, *path.parents): | ||
| try: | ||
| if ancestor.samefile(root): | ||
| return path.relative_to(ancestor) | ||
| except (FileNotFoundError, NotADirectoryError): | ||
| pass | ||
| return None | ||
|
|
||
|
|
||
| def _inside(path: Path, root: Path, label: str) -> Path: | ||
| try: | ||
| return path.relative_to(root) | ||
| except ValueError as exc: | ||
| raise ResolutionError(f"{label} is outside the scan root: {path}") from exc | ||
| relative = _relative_to(path, root) | ||
| if relative is None: | ||
| raise ResolutionError(f"{label} is outside the scan root: {path}") | ||
| return relative | ||
|
|
||
|
|
||
| def _resolve_root(repo: Path) -> Path: | ||
|
|
@@ -34,51 +45,111 @@ def _resolve_root(repo: Path) -> Path: | |
| return root | ||
|
|
||
|
|
||
| def list_security_md(repo: Path) -> list[str]: | ||
| def _scope_directory(root: Path, scope: Path, *, require_directory: bool = False) -> Path: | ||
| requested = scope.expanduser() | ||
| if not requested.is_absolute(): | ||
| requested = root / requested | ||
| try: | ||
| resolved = requested.resolve(strict=True) | ||
| except (OSError, RuntimeError) as exc: | ||
| raise ResolutionError(f"scan scope does not exist: {requested}") from exc | ||
| resolved = root / _inside(resolved, root, "scan scope") | ||
| if require_directory and not resolved.is_dir(): | ||
| raise ResolutionError(f"policy scope must be a directory: {requested}") | ||
| return resolved if resolved.is_dir() else resolved.parent | ||
|
|
||
|
|
||
| def _git_metadata(path: Path, root: Path, git_dirs: tuple[Path, ...]) -> bool: | ||
| relative = _inside(path, root, "policy path") | ||
| if any(_relative_to(path, directory) is not None for directory in git_dirs): | ||
| return True | ||
| current = root | ||
| for part in relative.parts: | ||
| current /= part | ||
| if part == ".git": | ||
| return True | ||
| if part.lower() == ".git": | ||
| marker = current.with_name(".git") | ||
| try: | ||
| if current.samefile(marker): | ||
| return True | ||
| except (FileNotFoundError, NotADirectoryError): | ||
| pass | ||
| return False | ||
|
|
||
|
|
||
| def _read_policy(policy: Path, root: Path, git_dirs: tuple[Path, ...] = ()) -> str | None: | ||
| try: | ||
| resolved = policy.resolve(strict=False) | ||
| except (OSError, RuntimeError) as exc: | ||
| raise ResolutionError(f"could not resolve SECURITY.md: {policy}") from exc | ||
| _inside(resolved, root, "SECURITY.md") | ||
| if _git_metadata(policy, root, git_dirs) or _git_metadata(resolved, root, git_dirs): | ||
| raise ResolutionError(f"SECURITY.md points into Git metadata: {policy}") | ||
| try: | ||
| metadata = resolved.stat(follow_symlinks=False) | ||
| except (FileNotFoundError, NotADirectoryError): | ||
| return None | ||
|
mldangelo-oai marked this conversation as resolved.
|
||
| if not stat.S_ISREG(metadata.st_mode): | ||
| raise ResolutionError(f"SECURITY.md must be a regular file: {policy}") | ||
| flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0) | ||
| with os.fdopen(os.open(resolved, flags), "rb") as policy_file: | ||
| metadata = os.fstat(policy_file.fileno()) | ||
| if not stat.S_ISREG(metadata.st_mode): | ||
| raise ResolutionError(f"SECURITY.md must be a regular file: {policy}") | ||
| if metadata.st_nlink > 1: | ||
| raise ResolutionError(f"SECURITY.md must not be hard-linked: {policy}") | ||
|
mldangelo-oai marked this conversation as resolved.
Outdated
|
||
| policy_bytes = policy_file.read(MAX_SECURITY_MD_BYTES + 1) | ||
| if len(policy_bytes) > MAX_SECURITY_MD_BYTES: | ||
| raise ResolutionError(f"SECURITY.md exceeds 1 MiB: {policy}") | ||
| try: | ||
| return policy_bytes.decode("utf-8") | ||
| except UnicodeDecodeError as exc: | ||
| raise ResolutionError(f"SECURITY.md is not valid UTF-8: {policy}") from exc | ||
|
|
||
|
|
||
| def list_security_md( | ||
| repo: Path, scope: Path | None = None, git_dirs: tuple[Path, ...] = () | ||
| ) -> list[str]: | ||
| """Return a stable, safely framed inventory without traversing Git metadata.""" | ||
| root = _resolve_root(repo) | ||
|
|
||
| def raise_walk_error(error: OSError) -> None: | ||
| raise error | ||
|
|
||
| policies: list[str] = [] | ||
| for directory, subdirectories, filenames in os.walk( | ||
| root, onerror=raise_walk_error, followlinks=False | ||
| selected = root if scope is None else _scope_directory(root, scope, require_directory=True) | ||
| if _git_metadata(selected, root, git_dirs): | ||
| raise ResolutionError(f"policy scope is inside Git metadata: {selected}") | ||
| for directory, subdirectories, _filenames in os.walk( | ||
| selected, onerror=raise_walk_error, followlinks=False | ||
| ): | ||
| safe_subdirectories: list[str] = [] | ||
| for name in sorted(subdirectories): | ||
| if name == ".git": | ||
| child = Path(directory) / name | ||
| if _git_metadata(child, root, git_dirs): | ||
| continue | ||
| directory_stat = (Path(directory) / name).stat(follow_symlinks=False) | ||
| directory_stat = child.stat(follow_symlinks=False) | ||
| if not stat.S_ISDIR(directory_stat.st_mode): | ||
| continue | ||
| reparse_point = getattr(stat, "FILE_ATTRIBUTE_REPARSE_POINT", 0) | ||
| if getattr(directory_stat, "st_file_attributes", 0) & reparse_point: | ||
| continue | ||
| safe_subdirectories.append(name) | ||
| subdirectories[:] = safe_subdirectories | ||
| if "SECURITY.md" not in filenames: | ||
| continue | ||
| policy = Path(directory) / "SECURITY.md" | ||
| if policy.is_file() or policy.is_symlink(): | ||
| policies.append(policy.relative_to(root).as_posix()) | ||
|
Comment on lines
156
to
157
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a repository contains a Useful? React with 👍 / 👎. |
||
| return sorted(policies) | ||
|
|
||
|
|
||
| def resolve_security_md(repo: Path, scope: Path) -> str: | ||
| def resolve_security_md(repo: Path, scope: Path, git_dirs: tuple[Path, ...] = ()) -> str: | ||
| """Return applicable SECURITY.md files, concatenated root to leaf.""" | ||
| root = _resolve_root(repo) | ||
|
|
||
| requested_scope = scope.expanduser() | ||
| if not requested_scope.is_absolute(): | ||
| requested_scope = root / requested_scope | ||
| try: | ||
| resolved_scope = requested_scope.resolve(strict=True) | ||
| except OSError as exc: | ||
| raise ResolutionError(f"scan scope does not exist: {requested_scope}") from exc | ||
| _inside(resolved_scope, root, "scan scope") | ||
|
|
||
| target_directory = resolved_scope if resolved_scope.is_dir() else resolved_scope.parent | ||
| target_directory = _scope_directory(root, scope) | ||
| if _git_metadata(target_directory, root, git_dirs): | ||
| raise ResolutionError(f"policy scope is inside Git metadata: {target_directory}") | ||
| relative_directory = _inside(target_directory, root, "scan scope") | ||
| directories = [root] | ||
| current = root | ||
|
|
@@ -89,18 +160,9 @@ def resolve_security_md(repo: Path, scope: Path) -> str: | |
| sections: list[str] = [] | ||
| for directory in directories: | ||
| policy = directory / "SECURITY.md" | ||
| if not policy.is_file(): | ||
| content = _read_policy(policy, root, git_dirs) | ||
| if content is None: | ||
| continue | ||
| resolved_policy = policy.resolve(strict=True) | ||
| _inside(resolved_policy, root, "SECURITY.md") | ||
| try: | ||
| with resolved_policy.open("rb") as policy_file: | ||
| policy_bytes = policy_file.read(MAX_SECURITY_MD_BYTES + 1) | ||
| if len(policy_bytes) > MAX_SECURITY_MD_BYTES: | ||
| raise ResolutionError(f"SECURITY.md exceeds 1 MiB: {policy}") | ||
| content = policy_bytes.decode("utf-8") | ||
| except UnicodeDecodeError as exc: | ||
| raise ResolutionError(f"SECURITY.md is not valid UTF-8: {policy}") from exc | ||
| if not content.strip(): | ||
| continue | ||
|
|
||
|
|
@@ -113,23 +175,62 @@ def resolve_security_md(repo: Path, scope: Path) -> str: | |
| return "\n".join(sections) | ||
|
|
||
|
|
||
| def inspect_security_policy( | ||
| repo: Path, scope: Path, git_dirs: tuple[Path, ...] = () | ||
| ) -> dict[str, object]: | ||
| """Return checked drafting evidence without interpreting policy as instructions.""" | ||
| root = _resolve_root(repo) | ||
| directory = _scope_directory(root, scope, require_directory=True) | ||
| selected = directory / "SECURITY.md" | ||
| if selected.is_symlink(): | ||
| raise ResolutionError(f"selected SECURITY.md must not be a symbolic link: {selected}") | ||
| previous = _read_policy(selected, root, git_dirs) | ||
| paths = set(list_security_md(root, directory, git_dirs)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: sdk/typescript/AGENTS.md:L22-L24 Useful? React with 👍 / 👎. |
||
| current = directory | ||
| while True: | ||
| paths.add((current / "SECURITY.md").relative_to(root).as_posix()) | ||
| if current == root: | ||
| break | ||
| current = current.parent | ||
| paths.update((".github/SECURITY.md", "docs/SECURITY.md")) | ||
| checked = [ | ||
| path for path in sorted(paths) if _read_policy(root / path, root, git_dirs) is not None | ||
| ] | ||
| return { | ||
| "previousContent": previous, | ||
| "guidance": resolve_security_md(root, directory, git_dirs), | ||
| "policyPaths": checked, | ||
| } | ||
|
|
||
|
|
||
| def parse_args() -> argparse.Namespace: | ||
| parser = argparse.ArgumentParser(description=__doc__) | ||
| parser.add_argument("--repo", required=True, type=Path, help="scan root directory") | ||
| parser.add_argument( | ||
| mode = parser.add_mutually_exclusive_group() | ||
| mode.add_argument( | ||
| "--list", | ||
| action="store_true", | ||
| help="write a JSON inventory of repository policy paths", | ||
| ) | ||
| mode.add_argument( | ||
| "--inspect", | ||
| action="store_true", | ||
| help="write checked drafting inputs as JSON", | ||
| ) | ||
|
mldangelo-oai marked this conversation as resolved.
|
||
| parser.add_argument( | ||
| "--scope", | ||
| type=Path, | ||
| help="existing file or directory within the scan root", | ||
| ) | ||
| parser.add_argument("--out", default=Path("-"), type=Path, help="output path, or - for stdout") | ||
| parser.add_argument( | ||
| "--git-dir", | ||
| action="append", | ||
| default=[], | ||
| type=Path, | ||
| help="exclude a Git metadata directory identified by the caller", | ||
| ) | ||
| args = parser.parse_args() | ||
| if args.list and args.scope is not None: | ||
| parser.error("--list cannot be combined with --scope") | ||
| if not args.list and args.scope is None: | ||
| parser.error("--scope is required unless --list is specified") | ||
| return args | ||
|
|
@@ -138,17 +239,27 @@ def parse_args() -> argparse.Namespace: | |
| def main() -> int: | ||
| args = parse_args() | ||
| try: | ||
| guidance = ( | ||
| json.dumps(list_security_md(args.repo), ensure_ascii=True) + "\n" | ||
| if args.list | ||
| else resolve_security_md(args.repo, args.scope) | ||
| ) | ||
| git_dirs = tuple(path.resolve(strict=True) for path in args.git_dir) | ||
| if args.inspect: | ||
| guidance = ( | ||
| json.dumps( | ||
| inspect_security_policy(args.repo, args.scope, git_dirs), ensure_ascii=True | ||
| ) | ||
| + "\n" | ||
| ) | ||
| elif args.list: | ||
| guidance = ( | ||
| json.dumps(list_security_md(args.repo, args.scope, git_dirs), ensure_ascii=True) | ||
| + "\n" | ||
| ) | ||
| else: | ||
| guidance = resolve_security_md(args.repo, args.scope, git_dirs) | ||
| if args.out == Path("-"): | ||
| sys.stdout.buffer.write(guidance.encode("utf-8")) | ||
| else: | ||
| args.out.parent.mkdir(parents=True, exist_ok=True) | ||
| args.out.write_text(guidance, encoding="utf-8") | ||
| except (OSError, ResolutionError) as exc: | ||
| except (OSError, RuntimeError, ResolutionError) as exc: | ||
| print(f"resolve_security_md.py: error: {exc}", file=sys.stderr) | ||
| return 2 | ||
| return 0 | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.