Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bb396bf
fix(pingora): verify documentation screenshots as binary evidence
seonghobae Aug 31, 2026
48922bc
fix(pingora): validate complete PNG evidence
seonghobae Aug 31, 2026
d52acce
fix(ci): validate PNG image streams
seonghobae Aug 31, 2026
5375113
Merge remote-tracking branch 'origin/main' into fix/pingora-image-pol…
seonghobae Aug 31, 2026
791441c
fix(pingora): validate conforming PNG evidence
seonghobae Aug 31, 2026
16bbe7d
fix(pingora): reject invalid palette indices
seonghobae Aug 31, 2026
d16d052
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 31, 2026
c178419
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Aug 31, 2026
5383146
Merge remote-tracking branch 'origin/main' into HEAD
seonghobae Sep 1, 2026
1520d30
test(opencode): align head-advance trust contracts
seonghobae Sep 1, 2026
c30f9d6
chore(pingora): sync latest central fixes
seonghobae Sep 1, 2026
3efa7de
chore(pingora): sync strict dispatch binding
seonghobae Sep 1, 2026
a4c5894
Merge main into Pingora image policy fix
seonghobae Sep 1, 2026
7ffc89d
Merge current main into Pingora image policy fix
seonghobae Sep 1, 2026
a397a4f
Merge branch 'main' into fix/pingora-image-policy-20260831
opencode-agent[bot] Sep 1, 2026
353b668
Merge branch 'main' into fix/pingora-image-policy-20260831
opencode-agent[bot] Sep 1, 2026
e93d6e4
Merge branch 'main' into fix/pingora-image-policy-20260831
opencode-agent[bot] Sep 1, 2026
8f95279
Merge branch 'main' into fix/pingora-image-policy-20260831
opencode-agent[bot] Sep 1, 2026
f78d2a0
Merge branch 'main' into fix/pingora-image-policy-20260831
opencode-agent[bot] Sep 1, 2026
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
6 changes: 6 additions & 0 deletions docs/adr/0019-cloudflare-pingora-edge-standard.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ so a governed shared implementation is required.
6. Initial migration does not use Pingora's experimental cache integration.
7. PHP workloads move to an HTTP application server or reviewed FastCGI adapter
behind Pingora before the public listener changes.
8. Documentation PNG screenshots and PDF papers without a text diff are verified
from bounded format evidence (a complete CRC-valid PNG chunk stream with
conforming chunk names, palette bounds, and palette indices whose bounded null- or
Adam7-interlaced decompressed scanlines match IHDR, or a PDF signature) and excluded
from runtime-content scanning;
runtime paths and malformed or unsupported binary evidence still fail closed.

## Consequences

Expand Down
17 changes: 17 additions & 0 deletions docs/doctoring/pingora-documentation-image-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Pingora documentation image evidence

The required Pingora gate previously sent a changed PNG screenshot through its
UTF-8 runtime-content decoder because GitHub omits text patches for binary files.
That rejected UI evidence before the policy could determine whether it described
an active edge runtime.

ADR-0019 now admits documentation PNG screenshots only when the bounded final
file is a complete CRC-valid PNG chunk stream ending at IEND with no trailing
payload, conforming chunk names, palette bounds and indices, and bounded null- or
Adam7-interlaced decompressed scanlines that match IHDR. A signature or
CRC-valid arbitrary IDAT is insufficient. Files in a runtime path, malformed signatures,
unsupported binary formats, and unavailable evidence continue to fail closed.
The gate establishes bounded binary evidence rather than general image-rendering
fidelity; optional ancillary-chunk semantics are outside this policy boundary.
`tests/test_pingora_edge_policy.py` covers the accepted PNG and the existing fake
PDF/runtime cases; targeted branch coverage remains 100%.
11 changes: 9 additions & 2 deletions docs/policies/PINGORA_EDGE_POLICY.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,15 @@ The organization-required `required-workflow-bootstrap` job runs trusted
base-branch scanner code at the immutable required-workflow SHA. It reads bounded
changed-file metadata and final UTF-8 content through GitHub's REST API. It does
not check out or execute pull-request content and receives only read permissions.
Malformed, truncated, binary, symlink, oversized, or unavailable evidence fails
closed.
Malformed, truncated, symlinked, oversized, or unavailable runtime evidence fails
closed. Documentation PNG screenshots and PDF papers without a text diff are
excluded only after bounded format verification; PNG evidence must be a complete
CRC-valid chunk stream ending at IEND with conforming chunk names, palette
bounds, and palette indices whose bounded null- or Adam7-interlaced decompressed
scanlines match IHDR.
This is a bounded binary-evidence classifier, not a general image renderer;
visual fidelity and optional ancillary-chunk semantics are outside this gate.
Other binary files remain unavailable evidence and fail closed.

## Exception process

Expand Down
1 change: 1 addition & 0 deletions docs/product-technical-gap-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ flowchart LR
| G-13 | hourly scheduler는 존재하지만 no-op/credential unavailable/queued Checks의 customer next action을 모든 caller가 동일한 receipt로 내는지 미확인이다 | 자동화가 실패해도 운영자가 무엇을 고쳐야 하는지 알 수 없다 | `skipped_credential_unavailable` receipt와 다음 행동 문구를 exact-head Checks로 검증하고, bounded receipt schema, retry floor, single-flight, no secret fallback을 모든 caller contract test로 고정한다 |
| G-14 | release/changelog/version 증거가 각 PR에 분산되고 현재 central repo 보호 main의 release candidate가 명확하지 않다 | 운영자는 어떤 기능이 supportable release인지 확인할 수 없다 | merge 후 release readiness ledger, CHANGELOG, semantic version/tag, rollback/operability evidence를 함께 갱신한다 |
| G-15 | 첨부파일 처리 경계가 제품별로 다르고, 1MB 상한은 업무 데이터와 맞지 않으며 미지원 MIME/컨테이너가 parser registry에서 명시적으로 pending/quarantine 되는지 확인되지 않았다. 현재 20MB 초과 파일 가능성과 PDF/HWP/HWPX·이미지·압축파일의 parse/sidecar 흐름을 하나의 exact contract로 묶지 못했다 | 큰 업무 첨부를 거부하거나 파싱 실패를 조용히 잃으면 고객의 메일·문서 업무가 중단된다 | naruon/newsdom-api 소유 PR에서 streaming upload, configurable bounded limit above 20MB, MIME sniffing, parser capability registry, quarantine/retry, source-position provenance, and ADR를 추가하고 size/unsupported-type/zip-bomb tests를 required evidence로 만든다 |
| G-16 | Required Pingora policy treated a changed documentation PNG screenshot as UTF-8 runtime evidence | Valid UI evidence blocked otherwise valid product PRs before policy evaluation | This branch verifies bounded PNG magic before exemption while runtime paths and malformed assets continue to fail closed; protected-main delivery remains the release gate |

## 4. 열린 PR live inventory

Expand Down
197 changes: 174 additions & 23 deletions scripts/ci/pingora_edge_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import os
import re
import sys
import zlib
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import Callable, Mapping, Sequence
Expand All @@ -38,7 +39,11 @@
# 1 MiB base64 ceiling -- rejecting a legitimate research-paper citation
# (this org's own "attach the relevant paper PDF" convention) for a reason
# that has nothing to do with the Nginx runtime policy this module enforces.
BINARY_DOCUMENT_SUFFIXES = frozenset({".pdf"})
BINARY_DOCUMENT_MAGIC = {
".pdf": (b"%PDF-",),
".png": (b"\x89PNG\r\n\x1a\n",),
}
PNG_SIGNATURE = BINARY_DOCUMENT_MAGIC[".png"][0]
SOURCE_TEST_SUFFIXES = frozenset({".py", ".pyi", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".rs"})
LICENSE_NAMES = frozenset({"license", "license.md", "copying", "copyrights", "notice"})
DOCUMENTATION_DIRECTORIES = frozenset({"doc", "docs", "documentation"})
Expand Down Expand Up @@ -171,7 +176,7 @@ def _is_documentation_or_source_fixture(path: str) -> bool:
"""Return whether *path* is prose, license text, or scanner source fixture.

Textual suffixes only: a ``.pdf`` is handled separately by
``_is_binary_documentation_pdf`` and gated on GitHub reporting no diff
``_is_binary_documentation_asset`` and gated on GitHub reporting no diff
``patch`` for it, so a textual file merely named with a ``.pdf`` suffix
(one GitHub *can* diff, meaning it could carry inspectable content) is
never exempted here.
Expand Down Expand Up @@ -206,15 +211,15 @@ def _is_documentation_or_source_fixture(path: str) -> bool:
return False


def _is_binary_documentation_pdf(changed: ChangedFile) -> bool:
"""Return whether *changed* is a plausibly binary documentation PDF.
def _is_binary_documentation_asset(changed: ChangedFile) -> bool:
"""Return whether *changed* is a plausibly binary documentation asset.

This is only the cheap, patch-presence pre-filter: GitHub's changed-files
API never returns a diff ``patch`` for a true binary file, so a missing
``patch`` is *necessary* but not *sufficient* evidence -- GitHub also
omits one for a textual diff that merely exceeds its own rendering
limit. A caller with network access (``evaluate_pull_request``) must
still confirm this with ``_pdf_evidence_confirms_binary`` before
still confirm this with ``_binary_documentation_evidence_confirms`` before
trusting it; a caller without one (this module's own unit tests calling
this function directly) is only checking the necessary condition.
"""
Expand All @@ -223,8 +228,9 @@ def _is_binary_documentation_pdf(changed: ChangedFile) -> bool:
return False
pure = PurePosixPath(changed.path)
return (
pure.suffix.lower() in BINARY_DOCUMENT_SUFFIXES
pure.suffix.lower() in BINARY_DOCUMENT_MAGIC
and _is_known_documentation_path(pure)
and _runtime_path_rule(changed.path) is None
Comment thread
seonghobae marked this conversation as resolved.
)


Expand Down Expand Up @@ -414,10 +420,7 @@ def _load_file_content(api_url: str, repository: str, path: str, head_sha: str,
raise PolicyError(f"Runtime policy candidate {path} is not valid UTF-8") from exc


_PDF_MAGIC_PREFIX = b"%PDF-"


def _pdf_evidence_confirms_binary(
def _binary_documentation_evidence_confirms(
changed: ChangedFile,
*,
api_url: str,
Expand All @@ -426,17 +429,18 @@ def _pdf_evidence_confirms_binary(
token: str,
opener: OpenJson,
) -> bool:
"""Return whether a claimed binary documentation PDF is genuinely binary.
"""Return whether a claimed binary documentation asset is genuine.

A missing diff ``patch`` alone is not proof of binary content: GitHub
also omits a patch for a textual diff that exceeds its own rendering
limit, well under this module's ``MAX_FILE_BYTES`` content-fetch
ceiling. Whenever the file's raw bytes can be fetched at all, this
verifies the real ``%PDF-`` magic prefix instead of trusting
verifies the declared format's magic prefix instead of trusting
patch-presence alone. Only a file whose content evidently exceeds the
Contents API's size ceiling -- the exact case ``_is_binary_documentation_pdf``
Contents API's size ceiling -- the exact case ``_is_binary_documentation_asset``
exists for, a cited, large research paper -- falls back to trusting the
path+suffix convention; every other content-evidence failure (a
path+suffix convention for oversized PDFs only; every other
content-evidence failure (a
malformed API response, corrupt base64, a declared size that does not
match the decoded bytes) propagates and fails the whole check closed,
same as for any other file that needs scanning.
Expand All @@ -445,22 +449,169 @@ def _pdf_evidence_confirms_binary(
try:
raw = _load_raw_file_bytes(api_url, repository, changed.path, head_sha, token, opener)
except ContentSizeExceededError:
return True
return raw.startswith(_PDF_MAGIC_PREFIX)
return PurePosixPath(changed.path).suffix.lower() == ".pdf"
Comment thread
seonghobae marked this conversation as resolved.
suffix = PurePosixPath(changed.path).suffix.lower()
if suffix == ".png":
return _is_complete_png(raw)
return raw.startswith(BINARY_DOCUMENT_MAGIC[suffix])


def _png_unfilter_row(filtered: bytes, previous: bytes, filter_type: int, bytes_per_pixel: int) -> bytes:
"""Reconstruct one PNG scanline for bounded indexed-pixel validation."""

reconstructed = bytearray(len(filtered))
for index, value in enumerate(filtered):
left = reconstructed[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
above = previous[index] if previous else 0
upper_left = previous[index - bytes_per_pixel] if previous and index >= bytes_per_pixel else 0
if filter_type == 0:
predictor = 0
elif filter_type == 1:
predictor = left
elif filter_type == 2:
predictor = above
elif filter_type == 3:
predictor = (left + above) // 2
else:
estimate = left + above - upper_left
distances = (abs(estimate - left), abs(estimate - above), abs(estimate - upper_left))
predictor = (left, above, upper_left)[distances.index(min(distances))]
reconstructed[index] = (value + predictor) & 0xFF
return bytes(reconstructed)


def _is_complete_png(raw: bytes) -> bool:
"""Validate one bounded PNG including its null- or Adam7-interlaced stream."""

if not raw.startswith(PNG_SIGNATURE):
return False
offset = len(PNG_SIGNATURE)
header: tuple[int, int, int, int, int] | None = None
palette_entries = 0
image_data: list[bytes] = []
image_data_closed = False
while offset + 12 <= len(raw):
length = int.from_bytes(raw[offset : offset + 4], "big")
chunk_end = offset + 12 + length
if chunk_end > len(raw):
return False
chunk_type = raw[offset + 4 : offset + 8]
chunk_data = raw[offset + 8 : offset + 8 + length]
expected_crc = int.from_bytes(raw[offset + 8 + length : chunk_end], "big")
if (
any(not (65 <= byte <= 90 or 97 <= byte <= 122) for byte in chunk_type)
or chunk_type[2] & 0x20
or zlib.crc32(chunk_type + chunk_data) != expected_crc
):
return False
if header is None:
if chunk_type != b"IHDR" or length != 13 or offset != len(PNG_SIGNATURE):
return False
width = int.from_bytes(chunk_data[0:4], "big")
height = int.from_bytes(chunk_data[4:8], "big")
bit_depth, color_type, compression, filtering, interlace = chunk_data[8:13]
allowed_depths = {
0: {1, 2, 4, 8, 16}, 2: {8, 16}, 3: {1, 2, 4, 8},
4: {8, 16}, 6: {8, 16},
}
if (
width == 0 or height == 0
or bit_depth not in allowed_depths.get(color_type, set())
or compression != 0 or filtering != 0 or interlace not in {0, 1}
):
Comment thread
seonghobae marked this conversation as resolved.
return False
header = (width, height, bit_depth, color_type, interlace)
elif chunk_type == b"IHDR":
return False
elif chunk_type == b"PLTE":
if palette_entries or image_data or length == 0 or length > 768 or length % 3:
return False
_width, _height, bit_depth, color_type, _interlace = header
if color_type == 3 and length // 3 > 1 << bit_depth:
return False
Comment thread
seonghobae marked this conversation as resolved.
Comment thread
seonghobae marked this conversation as resolved.
palette_entries = length // 3
elif chunk_type == b"IDAT":
if image_data_closed:
return False
image_data.append(chunk_data)
elif chunk_type == b"IEND":
if length != 0 or not image_data or chunk_end != len(raw):
return False
width, height, bit_depth, color_type, interlace = header
if (color_type == 3 and not palette_entries) or (
color_type in {0, 4} and palette_entries
):
return False
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color_type]
passes = (
((0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4),
(0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2))
if interlace else ((0, 0, 1, 1),)
)
scanlines: list[tuple[int, int, int]] = []
expected_size = 0
for x_start, y_start, x_step, y_step in passes:
if width <= x_start or height <= y_start:
continue
pass_width = (width - x_start + x_step - 1) // x_step
pass_height = (height - y_start + y_step - 1) // y_step
row_bytes = (pass_width * channels * bit_depth + 7) // 8
expected_size += pass_height * (row_bytes + 1)
if expected_size > MAX_RESPONSE_BYTES:
return False
scanlines.append((pass_height, row_bytes, pass_width))
decoder = zlib.decompressobj()
try:
decoded = decoder.decompress(b"".join(image_data), expected_size + 1)
except zlib.error:
return False
if (
len(decoded) != expected_size or not decoder.eof
or decoder.unused_data or decoder.unconsumed_tail
):
return False
decoded_offset = 0
for row_count, row_bytes, pass_width in scanlines:
previous = b""
for _ in range(row_count):
filter_type = decoded[decoded_offset]
if filter_type > 4:
return False
filtered = decoded[decoded_offset + 1 : decoded_offset + row_bytes + 1]
if color_type == 3:
reconstructed = _png_unfilter_row(filtered, previous, filter_type, 1)
mask = (1 << bit_depth) - 1
for pixel in range(pass_width):
bit_offset = pixel * bit_depth
palette_index = (
reconstructed[bit_offset // 8]
>> (8 - bit_depth - bit_offset % 8)
) & mask
if palette_index >= palette_entries:
return False
previous = reconstructed
Comment thread
seonghobae marked this conversation as resolved.
decoded_offset += row_bytes + 1
return decoded_offset == len(decoded)
elif chunk_type[0] & 0x20 == 0:
return False
elif image_data:
image_data_closed = True
Comment thread
seonghobae marked this conversation as resolved.
offset = chunk_end
return False


def _needs_content_scan(changed: ChangedFile) -> bool:
"""Return whether a changed final file can carry an active edge runtime.

A claimed binary documentation PDF (``_is_binary_documentation_pdf``)
A claimed binary documentation asset (``_is_binary_documentation_asset``)
exempts here on the cheap, offline pre-filter alone; ``evaluate_pull_request``
never actually relies on that -- it runs ``_pdf_evidence_confirms_binary``
never actually relies on that -- it runs ``_binary_documentation_evidence_confirms``
for that case before this function is even consulted.
"""

if changed.status == "removed" or _is_documentation_or_source_fixture(changed.path):
return False
if _is_binary_documentation_pdf(changed):
if _is_binary_documentation_asset(changed):
return False
if not changed.patch_available:
return True
Expand Down Expand Up @@ -499,17 +650,17 @@ def evaluate_pull_request(
changed_files = _load_changed_files(api_url.rstrip("/"), repository, pull_request, token, opener)
violations: list[Violation] = []
for changed in changed_files:
# A claimed binary documentation PDF gets its own network-verified
# A claimed binary documentation asset gets its own network-verified
# check ahead of _needs_content_scan's patch-presence-only signal:
# a missing patch does not by itself prove binary content (GitHub
# also omits one for an oversized textual diff), so this confirms
# the real %PDF- magic prefix whenever the bytes can be fetched at
# the format's magic prefix whenever the bytes can be fetched at
# all, falling back to the path+suffix convention only when the
# content genuinely exceeds the Contents API's size ceiling. A
# removed file has no head content to fetch at all -- _needs_content_scan
# already special-cases this the same way for every other file.
if changed.status != "removed" and _is_binary_documentation_pdf(changed):
if _pdf_evidence_confirms_binary(
if changed.status != "removed" and _is_binary_documentation_asset(changed):
if _binary_documentation_evidence_confirms(
changed,
api_url=api_url.rstrip("/"),
repository=repository,
Comment thread
seonghobae marked this conversation as resolved.
Expand Down
Loading
Loading