Skip to content
Closed
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
291 changes: 291 additions & 0 deletions .github/workflows/naver-readonly-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
name: NAVER read-only audit runner

on:
pull_request:
branches:
- main
types: [opened, synchronize, reopened]

permissions:
contents: read

jobs:
audit:
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && github.head_ref == 'security/naver-readonly-audit-20260819' }}
runs-on: ubuntu-latest
timeout-minutes: 12
steps:
- name: Test public CLOVA search APIs with harmless markers
shell: bash
run: |
set -euo pipefail
ROOT="$RUNNER_TEMP/naver-clova-api"
OUT="$ROOT/out"
mkdir -p "$OUT/responses"

cat > "$ROOT/audit.py" <<'PY'
from __future__ import annotations

import hashlib
import json
import os
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

ROOT = Path(os.environ['RUNNER_TEMP']) / 'naver-clova-api'
OUT = ROOT / 'out'
RESPONSES = OUT / 'responses'
RESPONSES.mkdir(parents=True, exist_ok=True)

BASE = 'https://clova.ai'
MARKER = 'NBB_SAFE_20260819_5f6f2'
HTML_PROBE = MARKER + '<b data-nbb-probe="5f6f2">SAFE</b>'
SYNTAX_PROBE = MARKER + '["{'
PARAM_PROBE = MARKER + '&nbb_extra=5f6f2'
ORIGIN = 'https://example.invalid'
UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/142 Safari/537.36'
MAX_BYTES = 2 * 1024 * 1024

SEARCH_ENDPOINTS = [
('tech-blog', '/api/techBlog', {'locale': 'ko', 'after': '', 'category': 'tech-blog'}, 'term'),
('hcx-tech-blog', '/api/hcxTechBlogSearchOnly', {'locale': 'ko', 'after': '', 'category': 'tech-blog'}, 'term'),
('hcx-use-case', '/api/hcxUseCaseSearchOnly', {'locale': 'ko', 'after': ''}, 'term'),
('hcx-products', '/api/hcxAiProductsSearchOnly', {'locale': 'ko', 'after': ''}, 'term'),
]
AUX_ENDPOINTS = [
('popular-tags', '/api/hcxPopularTags', {'locale': 'ko'}),
('use-cases', '/api/hcxUseCase', {'locale': 'ko', 'after': '', 'subCategory': ''}),
('tech-blog-tag', '/api/techBlogByTag', {'locale': 'ko', 'after': '', 'category': 'tech-blog', 'tag': 'ai'}),
]
PROBES = [
('baseline', MARKER),
('html', HTML_PROBE),
('syntax', SYNTAX_PROBE),
('parameter', PARAM_PROBE),
]

request_count = 0
total_bytes = 0

def make_url(path: str, params: dict[str, str]) -> str:
return BASE + path + '?' + urllib.parse.urlencode(params, doseq=False, safe='')

def fetch(url: str, origin: str | None = None) -> tuple[int, dict[str, str], bytes, str | None]:
global request_count, total_bytes
headers = {
'User-Agent': UA,
'Accept': 'application/json,text/plain,*/*',
'Accept-Encoding': 'identity',
}
if origin:
headers['Origin'] = origin
request_count += 1
req = urllib.request.Request(url, headers=headers, method='GET')
try:
with urllib.request.urlopen(req, timeout=25) as resp:
body = resp.read(MAX_BYTES + 1)
error = None
if len(body) > MAX_BYTES:
body = body[:MAX_BYTES]
error = 'truncated'
result = int(resp.status), {k.lower(): v for k, v in resp.headers.items()}, body, error
except urllib.error.HTTPError as exc:
body = exc.read(MAX_BYTES + 1)[:MAX_BYTES]
result = int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, body, f'HTTP {exc.code}'
except Exception as exc: # noqa: BLE001
body = b''
result = 0, {}, body, f'{type(exc).__name__}: {exc}'
total_bytes += len(body)
time.sleep(1.0)
return result

def walk_json(value: Any, path: str = '$') -> list[tuple[str, str]]:
out: list[tuple[str, str]] = []
if isinstance(value, dict):
for key, child in value.items():
out.extend(walk_json(child, f'{path}.{key}'))
elif isinstance(value, list):
for index, child in enumerate(value[:500]):
out.extend(walk_json(child, f'{path}[{index}]'))
elif isinstance(value, str):
out.append((path, value))
return out

def inspect_body(body: bytes, headers: dict[str, str]) -> dict[str, Any]:
text = body.decode('utf-8', errors='replace')
parsed: Any = None
parse_error = None
try:
parsed = json.loads(text)
except Exception as exc: # noqa: BLE001
parse_error = f'{type(exc).__name__}: {exc}'
strings = walk_json(parsed) if parsed is not None else [('$raw', text)]
reflections = []
html_fields = []
error_fields = []
sensitive_key_hits = []
for path, value in strings:
if MARKER in value:
reflections.append({'path': path, 'value': value[:2500]})
if re.search(r'<(?:script|img|svg|iframe|object|embed|style|a|p|div|span|em|b)\b|on\w+\s*=|javascript:', value, re.I):
html_fields.append({'path': path, 'value': value[:2500]})
if re.search(r'(?:stack|traceback|graphql|syntax error|exception|internal server|wpgraphql|wordpress|query failed)', value, re.I):
error_fields.append({'path': path, 'value': value[:4000]})
if isinstance(parsed, dict):
def scan_keys(obj: Any, path: str = '$') -> None:
if isinstance(obj, dict):
for key, child in obj.items():
if re.search(r'(?:secret|token|password|authorization|cookie|email|phone|private|draft|internal)', str(key), re.I):
sensitive_key_hits.append({'path': f'{path}.{key}', 'type': type(child).__name__})
scan_keys(child, f'{path}.{key}')
elif isinstance(obj, list):
for index, child in enumerate(obj[:500]):
scan_keys(child, f'{path}[{index}]')
scan_keys(parsed)
return {
'text_length': len(text),
'sha256': hashlib.sha256(body).hexdigest(),
'json_parsed': parsed is not None,
'json_parse_error': parse_error,
'top_level_type': type(parsed).__name__ if parsed is not None else None,
'top_level_keys': sorted(parsed.keys()) if isinstance(parsed, dict) else None,
'marker_reflections': reflections[:50],
'html_like_fields': html_fields[:100],
'error_like_fields': error_fields[:50],
'sensitive_key_name_hits': sensitive_key_hits[:100],
}

records = []
counter = 0
for endpoint_name, path, defaults, term_name in SEARCH_ENDPOINTS:
for probe_name, probe_value in PROBES:
params = dict(defaults)
params[term_name] = probe_value
url = make_url(path, params)
status, headers, body, error = fetch(url)
counter += 1
filename = f'{counter:02d}-{endpoint_name}-{probe_name}.bin'
(RESPONSES / filename).write_bytes(body)
records.append({
'endpoint': endpoint_name,
'probe': probe_name,
'url': url,
'status': status,
'error': error,
'content_type': headers.get('content-type'),
'headers': {
key: headers.get(key)
for key in ('access-control-allow-origin', 'access-control-allow-credentials', 'vary', 'cache-control', 'x-powered-by')
},
'body_file': filename,
'inspection': inspect_body(body, headers),
})

params = dict(defaults)
params[term_name] = MARKER
url = make_url(path, params)
status, headers, body, error = fetch(url, ORIGIN)
counter += 1
filename = f'{counter:02d}-{endpoint_name}-cors.bin'
(RESPONSES / filename).write_bytes(body)
records.append({
'endpoint': endpoint_name,
'probe': 'cors',
'url': url,
'status': status,
'error': error,
'content_type': headers.get('content-type'),
'headers': {
key: headers.get(key)
for key in ('access-control-allow-origin', 'access-control-allow-credentials', 'vary', 'cache-control', 'x-powered-by')
},
'body_file': filename,
'inspection': inspect_body(body, headers),
})

for endpoint_name, path, params in AUX_ENDPOINTS:
url = make_url(path, params)
status, headers, body, error = fetch(url, ORIGIN)
counter += 1
filename = f'{counter:02d}-{endpoint_name}.bin'
(RESPONSES / filename).write_bytes(body)
records.append({
'endpoint': endpoint_name,
'probe': 'baseline-cors',
'url': url,
'status': status,
'error': error,
'content_type': headers.get('content-type'),
'headers': {
key: headers.get(key)
for key in ('access-control-allow-origin', 'access-control-allow-credentials', 'vary', 'cache-control', 'x-powered-by')
},
'body_file': filename,
'inspection': inspect_body(body, headers),
})

reflected = [r for r in records if r['inspection']['marker_reflections']]
server_errors = [r for r in records if r['status'] >= 500 or r['inspection']['error_like_fields']]
cors_reflections = [r for r in records if r['headers'].get('access-control-allow-origin') == ORIGIN]
html_reflections = [
r for r in records
if r['inspection']['marker_reflections'] and any(MARKER in item['value'] for item in r['inspection']['html_like_fields'])
]
report = {
'generated_at_utc': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
'safety': {'methods': ['GET'], 'authentication': False, 'cookies': False, 'state_changes': False},
'request_count': request_count,
'downloaded_bytes': total_bytes,
'marker': MARKER,
'origin_probe': ORIGIN,
'records': records,
'triage': {
'records_with_marker_reflection': len(reflected),
'records_with_marker_inside_html_like_field': len(html_reflections),
'records_with_server_error_or_error_detail': len(server_errors),
'records_reflecting_arbitrary_origin': len(cors_reflections),
},
}
(OUT / 'summary.json').write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding='utf-8')
public = {'audit_complete': True, 'requests': request_count, **report['triage']}
(ROOT / 'public-summary.json').write_text(json.dumps(public, indent=2), encoding='utf-8')
print(json.dumps(public))
PY

python3 "$ROOT/audit.py"
tar -C "$OUT" -czf "$ROOT/evidence.tar.gz" .
openssl rand 32 > "$ROOT/key.bin"
openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -in "$ROOT/evidence.tar.gz" -out "$ROOT/evidence.tar.gz.enc" -pass file:"$ROOT/key.bin"
cat > "$ROOT/public.pem" <<'PEM'
-----BEGIN PUBLIC KEY-----
MIIBojANBgkqhkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAu9ZB1cO7Xoi8jMR0XGgd
qHKKqUkz+hSyrBFdvFJ5ca/yo7yeKN0ZqRumi99Sbl4v49jTT7oF6s1YDrNpjRWS
iWgVt4FhOo/8fbs8ZOQDOUZQLqWr0zTbvrmXnG5uMTn5dmOlE5MaWiQA0CvxUuXK
chNicVzG6/kBSv6iHZNhw3gKuTFbN0httgqss4AKkXNgl3UPhEHEQEhw241o+PxX
0vzVqE0kFDNlkvVzOX1s/8w03qQwDCHI7SpqwWAXJP/2F6Av4VzXr95jwUE/Icy2
jUxapf3tjpD47NpGAhtQRukIF/fAFA6wfSpveEYgHKctn9bxfLZriKWGKzXCPzB9
oBpU5mnToaMAHvQABbeWDRpbHeVm3TkVgXNq/qGJhmEsqP1O80yiEyHAUFD0XINZ
KA4DTlLuLmM9piz9r9VNZEakL9Co+yiWtq5CdLy+nwh205z2/vT55keYBzonJiAS
md2fazTVTarygfyb+it15LkUN3sZzAMJVpuX4OY0H1h1AgMBAAE=
-----END PUBLIC KEY-----
PEM
openssl pkeyutl -encrypt -pubin -inkey "$ROOT/public.pem" -in "$ROOT/key.bin" -out "$ROOT/key.enc" -pkeyopt rsa_padding_mode:oaep -pkeyopt rsa_oaep_md:sha256
sha256sum "$ROOT/evidence.tar.gz.enc" "$ROOT/key.enc" > "$ROOT/checksums.txt"
rm -rf "$OUT" "$ROOT/evidence.tar.gz" "$ROOT/key.bin" "$ROOT/public.pem" "$ROOT/audit.py"

- name: Upload encrypted API evidence
uses: actions/upload-artifact@v4
with:
name: naver-clova-api-evidence-encrypted
path: |
${{ runner.temp }}/naver-clova-api/evidence.tar.gz.enc
${{ runner.temp }}/naver-clova-api/key.enc
${{ runner.temp }}/naver-clova-api/checksums.txt
${{ runner.temp }}/naver-clova-api/public-summary.json
retention-days: 1
if-no-files-found: error
Loading