Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,7 @@ Explicitly defining `allow_pickle=False` is a robust defense-in-depth practice.
**Vulnerability:** The functions `parse_generated_item_candidate` and `_contract_object` used `json.loads` directly on string payloads before strictly enforcing depth limits over the string itself. A maliciously nested JSON string (e.g. `{"a": {"a": ...}}`) could exceed the Python maximum recursion limit, crashing the process with a `RecursionError` and causing a Denial of Service (DoS) attack, because Python's built-in `json.loads` recurses natively while decoding.
**Learning:** Checking for JSON nested depth after decoding using `json.loads` (or implicitly relying on string size constraints) is insufficient to prevent recursion crashes on deep but compact objects. Depth checking must happen by scanning the raw string stream prior to any decoding engine invocations.
**Prevention:** Always implement a character-level depth limit scanner (`_validate_raw_json_depth`) and enforce it on raw strings before passing them to `json.loads`.
## 2026-08-21 - [JSON Denial of Service (DoS) Vulnerability in Subprocess stdout]
**Vulnerability:** `scripts/build_pr_queue_governance.py`์™€ `scripts/build_procurement_due_diligence.py`์—์„œ ์„œ๋ธŒํ”„๋กœ์„ธ์Šค์˜ `stdout` ๊ฒฐ๊ณผ๋ฌผ์— ๋Œ€ํ•ด ํฌ๊ธฐ๋‚˜ ์žฌ๊ท€ ๊นŠ์ด ๊ฒ€์ฆ ์—†์ด `json.loads`๋ฅผ ์ง์ ‘ ์‚ฌ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค. ์•…์˜์ ์œผ๋กœ ํฌ๊ฑฐ๋‚˜ ๊นŠ๊ฒŒ ์ค‘์ฒฉ๋œ JSON ํŽ˜์ด๋กœ๋“œ ๋ฐ˜ํ™˜ ์‹œ ๋ฉ”๋ชจ๋ฆฌ ๊ณ ๊ฐˆ ๋˜๋Š” ์žฌ๊ท€ ํ•œ๋„ ์ดˆ๊ณผ๋กœ ์ธํ•œ ์„œ๋น„์Šค ๊ฑฐ๋ถ€(DoS)๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
**Learning:** ํŒŒ์ผ ์ž…๋ ฅ๋ฟ๋งŒ ์•„๋‹ˆ๋ผ ์™ธ๋ถ€ ๋ช…๋ น์–ด(`gh` ๋“ฑ)์˜ ์ถœ๋ ฅ ๊ฒฐ๊ณผ๋ฅผ ํŒŒ์‹ฑํ•  ๋•Œ๋„ ๋ฐ”์šด๋””๋“œ(bounded) JSON ํŒŒ์„œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊นŠ์ด์™€ ํฌ๊ธฐ๋ฅผ ์ œํ•œํ•ด์•ผ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.
**Prevention:** ์„œ๋ธŒํ”„๋กœ์„ธ์Šค์˜ ์ถœ๋ ฅ์„ ์—ญ์ง๋ ฌํ™”ํ•  ๋•Œ๋Š” ํ•ญ์ƒ `scripts._bounded_json`์˜ `parse_json_bounded`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ฒ€์ฆ ํ›„ `json.loads`๊ฐ€ ์ˆ˜ํ–‰๋˜๋„๋ก ํ•ฉ๋‹ˆ๋‹ค.
6 changes: 3 additions & 3 deletions scripts/build_pr_queue_governance.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
from urllib.parse import urlparse

try:
from scripts._bounded_json import read_json_object
from scripts._bounded_json import parse_json_bounded, read_json_object
except ModuleNotFoundError:
from _bounded_json import read_json_object
from _bounded_json import parse_json_bounded, read_json_object


RISK_COUNT_KEYS = [
Expand Down Expand Up @@ -162,7 +162,7 @@ def _json_from_completed(completed: subprocess.CompletedProcess[str]) -> Any:
"""Decode command stdout when the command succeeded and emitted JSON."""
if completed.returncode != 0 or not completed.stdout.strip():
return None
return json.loads(completed.stdout)
return parse_json_bounded(completed.stdout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

๐Ÿ“ Info: Exception change to ValueError stays compatible

Invalid subprocess JSON now raises ValueError rather than json.JSONDecodeError. JSONDecodeError subclasses ValueError, so existing handlers still catch it, and neither changed call site wraps the parse, so propagation matches prior behavior.

Open in Devin Review

Was this helpful? React with ๐Ÿ‘ or ๐Ÿ‘Ž to provide feedback.



def _is_transient_gh_stderr(stderr: str) -> bool:
Expand Down
24 changes: 21 additions & 3 deletions scripts/build_procurement_due_diligence.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,9 @@
GIT_METADATA_TIMEOUT_SECONDS = 5

try:
from scripts._bounded_json import read_json_object
from scripts._bounded_json import parse_json_bounded, read_json_object
except ModuleNotFoundError:
from _bounded_json import read_json_object
from _bounded_json import parse_json_bounded, read_json_object


POLICY_FILES = [
Expand All @@ -38,6 +38,7 @@


def _sha256(path: Path) -> str:
"""Return the SHA-256 digest of a file's bytes."""
digest = hashlib.sha256()
with path.open("rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
Expand All @@ -51,13 +52,15 @@ def _read_json(path: Path) -> dict[str, Any]:


def _resolve_path(value: str | Path, *, base: Path) -> Path:
"""Resolve a relative input path against the evidence repository root."""
path = Path(value)
if path.is_absolute():
return path
return base / path


def _metadata_dict(text: str) -> dict[str, str]:
"""Extract the package metadata fields used by procurement checks."""
parsed = Parser().parsestr(text)
return {
key: parsed.get(key, "")
Expand All @@ -66,6 +69,7 @@ def _metadata_dict(text: str) -> dict[str, str]:


def parse_wheel(path: Path) -> dict[str, Any]:
"""Inspect a wheel for required metadata members and its digest."""
required = {"METADATA": None, "WHEEL": None, "RECORD": None}
metadata: dict[str, str] = {}
members: list[str] = []
Expand Down Expand Up @@ -101,6 +105,7 @@ def parse_wheel(path: Path) -> dict[str, Any]:


def parse_sdist(path: Path) -> dict[str, Any]:
"""Inspect a source distribution for its package metadata and digest."""
metadata: dict[str, str] = {}
pkg_info_name = None
if not path.exists():
Expand Down Expand Up @@ -133,6 +138,7 @@ def parse_sdist(path: Path) -> dict[str, Any]:


def _project_metadata(repo_root: Path) -> dict[str, str]:
"""Read the normalized project identity from pyproject.toml."""
pyproject = repo_root / "pyproject.toml"
try:
import tomllib
Expand All @@ -148,6 +154,7 @@ def _project_metadata(repo_root: Path) -> dict[str, str]:


def _parse_project_metadata(text: str) -> dict[str, str]:
"""Parse project identity fields when tomllib is unavailable."""
in_project = False
values = {"name": "", "version": "", "requires_python": ""}
for raw_line in text.splitlines():
Expand Down Expand Up @@ -189,6 +196,7 @@ def _source_commit(repo_root: Path) -> str:
def _check(
name: str, category: str, ok: bool, detail: str, **metadata: Any
) -> dict[str, Any]:
"""Build one serializable due-diligence check record."""
payload: dict[str, Any] = {
"name": name,
"category": category,
Expand All @@ -200,6 +208,7 @@ def _check(


def _policy_checks(repo_root: Path) -> list[dict[str, Any]]:
"""Check that required policy and CI files exist and are non-empty."""
checks = []
for relative in POLICY_FILES:
path = repo_root / relative
Expand Down Expand Up @@ -228,6 +237,7 @@ def _policy_checks(repo_root: Path) -> list[dict[str, Any]]:
def _commercial_checks(
path: Path, *, contract_value_krw: int
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
"""Validate the commercial release manifest and required artifact digests."""
if not path.exists():
return {}, [
_check(
Expand Down Expand Up @@ -277,6 +287,7 @@ def _commercial_checks(


def _github_snapshot(repo: str, *, offline: bool) -> dict[str, Any]:
"""Capture bounded live or explicitly offline GitHub release evidence."""
if offline:
return {"mode": "offline", "repo": repo, "checks": {"snapshot_recorded": True}}
snapshot: dict[str, Any] = {"mode": "live", "repo": repo}
Expand Down Expand Up @@ -308,7 +319,7 @@ def _github_snapshot(repo: str, *, offline: bool) -> dict[str, Any]:
snapshot[name] = {
"ok": completed.returncode == 0,
"returncode": completed.returncode,
"data": json.loads(completed.stdout)
"data": parse_json_bounded(completed.stdout)
if completed.returncode == 0 and completed.stdout.strip()
else None,
"stderr": completed.stderr.strip(),
Expand All @@ -328,6 +339,7 @@ def _github_snapshot(repo: str, *, offline: bool) -> dict[str, Any]:


def _github_checks(snapshot: dict[str, Any]) -> list[dict[str, Any]]:
"""Convert a GitHub snapshot into repository-state check records."""
if snapshot.get("mode") == "offline":
return [
_check(
Expand Down Expand Up @@ -372,10 +384,12 @@ def _github_checks(snapshot: dict[str, Any]) -> list[dict[str, Any]]:


def _content_security_policy() -> str:
"""Return the restrictive CSP used by the generated HTML report."""
return "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"


def _report_css() -> str:
"""Return the self-contained accessible stylesheet for the report."""
return """
:root { color: #172026; background: #f5f7f8; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
* { box-sizing: border-box; }
Expand Down Expand Up @@ -429,6 +443,7 @@ def _report_css() -> str:


def _render_report(manifest: dict[str, Any]) -> str:
"""Render the procurement manifest as an escaped standalone HTML report."""
checks = manifest.get("checks", [])
rows = []
for check in checks:
Expand Down Expand Up @@ -497,6 +512,7 @@ def _render_report(manifest: dict[str, Any]) -> str:


def build_procurement_due_diligence(args: argparse.Namespace) -> dict[str, Any]:
"""Build package, policy, GitHub, and release evidence under the output directory."""
repo_root = Path(args.repo_root).resolve()
dist_dir = _resolve_path(args.dist, base=repo_root).resolve()
commercial_path = _resolve_path(
Expand Down Expand Up @@ -593,6 +609,7 @@ def build_procurement_due_diligence(args: argparse.Namespace) -> dict[str, Any]:


def build_parser() -> argparse.ArgumentParser:
"""Create the command-line parser for procurement evidence generation."""
parser = argparse.ArgumentParser(
description="Build procurement due-diligence evidence for fast-mlsirm."
)
Expand Down Expand Up @@ -628,6 +645,7 @@ def build_parser() -> argparse.ArgumentParser:


def main(argv: list[str] | None = None) -> int:
"""Run procurement evidence generation and return a process exit code."""
parser = build_parser()
args = parser.parse_args(argv)
try:
Expand Down
Loading