Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## [Unreleased]

### 보안 강화
- control plane API 하드닝: (1) 요청 본문을 10MiB로 캡하고 음수 Content-Length를 거부합니다(유효 키 소지자의 OOM/EOF-hang 방지). (2) `limit`/`offset` 쿼리 파라미터를 클램프합니다 — sqlite에서 `LIMIT -1`은 무제한이므로 음수를 그대로 전달하면 페이지네이션 캡이 우회됐습니다(list 1..1000, trend 1..365, offset ≥0).

### 추가
- `appguardrail fix` 명령 — 안전하고 결정적인 자동 수정을 적용합니다(기본 dry-run diff, `--apply`로 기록). 의미를 바꾸지 않는 순수 additive 변환만 수행하며, 첫 변환으로 외부 `target="_blank"` 링크에 `rel="noopener noreferrer"`를 추가합니다(reverse tabnabbing 방지). 동작을 바꾸는 수정(시크릿→env 등)은 위험하므로 자동 적용하지 않고 fix-pack 프롬프트로 남깁니다. scan→fix→verify 루프를 안전하게 닫습니다.
- `appguardrail serve` — 멀티테넌트 **control-plane API**(스캔 인제스트 + 히스토리). 일회성 CLI를 넘어, CI가 매 스캔의 `appguardrail.findings.v1`을 org별 API 키로 영속 저장하고 시간에 따른 추이를 조회할 수 있는 지속형 백본입니다. stdlib(sqlite3 + http.server)만 사용하며 org별 테넌트 격리를 강제합니다.
Expand Down
19 changes: 15 additions & 4 deletions appguardrail_core/controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,11 +436,14 @@ def do_GET(self):
path = parsed.path
qs = parse_qs(parsed.query)

def _qint(name, default):
def _qint(name, default, lo, hi):
# Clamp: sqlite treats LIMIT -1 as "no limit", so never pass
# negatives through; hi keeps a single request bounded.
try:
return int(qs.get(name, [default])[0])
value = int(qs.get(name, [default])[0])
except (ValueError, TypeError):
return default
return max(lo, min(hi, value))

if path in ("/", "/console", "/index.html"):
self.send_response(200)
Expand All @@ -456,18 +459,26 @@ def _qint(name, default):
return self._json(401, {"error": "invalid or missing API key"})
org, _role = auth
if path == "/api/v1/scans":
return self._json(200, {"scans": list_scans(conn, org, _qint("limit", 100), _qint("offset", 0))})
return self._json(200, {"scans": list_scans(conn, org, _qint("limit", 100, 1, 1000), _qint("offset", 0, 0, 10**9))})
if path == "/api/v1/scans/trend":
return self._json(200, {"trend": scan_trend(conn, org, _qint("limit", 30))})
return self._json(200, {"trend": scan_trend(conn, org, _qint("limit", 30, 1, 365))})
m = re.match(r"^/api/v1/scans/(\d+)$", path)
if m:
scan = get_scan(conn, org, int(m.group(1)))
return self._json(200, scan) if scan else self._json(404, {"error": "not found"})
self._json(404, {"error": "not found"})

_MAX_BODY = 10 * 1024 * 1024 # 10 MiB — plenty for findings, blocks OOM posts

def _body(self):
try:
length = int(self.headers.get("Content-Length", 0))
except (ValueError, TypeError):
return None
if length < 0 or length > self._MAX_BODY:
# Negative reads until EOF; oversized bodies exhaust memory.
return None
try:
return json.loads(self.rfile.read(length) or b"{}")
except (ValueError, TypeError):
return None
Expand Down
46 changes: 46 additions & 0 deletions tests/test_controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -318,3 +318,49 @@ class _R: # minimal stand-in, urlopen result is ignored
org_name="Acme", new_findings=findings) is True
assert posted["body"] == generic
assert "blocks" not in posted["body"]


# ---- API hardening: body cap + query clamps ----

def test_negative_and_huge_limit_clamped(server):
base, key = server
for _ in range(3):
_req("POST", f"{base}/api/v1/scans", key, {"repo": "r", "findings": []})
# limit=-1 means UNBOUNDED in sqlite — must be clamped, not passed through
_, page = _req("GET", f"{base}/api/v1/scans?limit=-1", key)
assert 1 <= len(page["scans"]) <= 1000
_, page2 = _req("GET", f"{base}/api/v1/scans?limit=999999&offset=-5", key)
assert len(page2["scans"]) <= 1000 # hi clamp; negative offset -> 0


def test_oversized_body_rejected(server):
import http.client
from urllib.parse import urlparse as _u
base, key = server
u = _u(base)
conn = http.client.HTTPConnection(u.hostname, u.port, timeout=10)
# Claim an over-cap body; server must 400 without reading it all.
conn.putrequest("POST", "/api/v1/scans")
conn.putheader("Authorization", f"Bearer {key}")
conn.putheader("Content-Type", "application/json")
conn.putheader("Content-Length", str(50 * 1024 * 1024))
conn.endheaders()
conn.send(b"{") # send a byte so the server can respond
resp = conn.getresponse()
assert resp.status == 400
conn.close()


def test_negative_content_length_rejected(server):
import http.client
from urllib.parse import urlparse as _u
base, key = server
u = _u(base)
conn = http.client.HTTPConnection(u.hostname, u.port, timeout=10)
conn.putrequest("POST", "/api/v1/scans")
conn.putheader("Authorization", f"Bearer {key}")
conn.putheader("Content-Length", "-1")
conn.endheaders()
resp = conn.getresponse()
assert resp.status == 400
conn.close()