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]

### 보안
- 리포트 출력 하드닝 — 생성된 markdown 리포트가 HTML로 렌더될 때 악성 finding 내용(예: 외부 엔진이 스캔한 코드의 `<script>`)이 주입되지 않도록, 프로즈 필드(message/remediation/verification)를 HTML 이스케이프하고 snippet의 code-fence 탈출을 무력화합니다(모든 리포트 타입). rule_id/category/context 등 제약된 식별자는 그대로 둡니다.

### 추가
- `appguardrail fix` 명령 — 안전하고 결정적인 자동 수정을 적용합니다(기본 dry-run diff, `--apply`로 기록). 의미를 바꾸지 않는 순수 additive 변환만 수행하며, 첫 변환으로 외부 `target="_blank"` 링크에 `rel="noopener noreferrer"`를 추가합니다(reverse tabnabbing 방지). 동작을 바꾸는 수정(시크릿→env 등)은 위험하므로 자동 적용하지 않고 fix-pack 프롬프트로 남깁니다. scan→fix→verify 루프를 안전하게 닫습니다.
- `appguardrail sbom` — 의존성 매니페스트(npm `package-lock.json`/`package.json`, Python `requirements.txt`)에서 CycloneDX 1.5 SBOM을 생성합니다. 무의존성(stdlib)으로 동작하며, lockfile이 있으면 resolved 버전을, 없으면 매니페스트의 declared 범위를 사용하고 컴포넌트 properties에 출처를 기록합니다. 공급망 실사(due diligence)의 기본 산출물입니다.
Expand Down
38 changes: 36 additions & 2 deletions appguardrail_core/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def render_buyer_diligence_report(
) -> str:
"""Render a buyer-diligence markdown report from normalized findings."""
context = context or ReportContext()
normalized = [normalize_finding(finding) for finding in findings]
normalized = [_sanitize_for_markdown(normalize_finding(finding)) for finding in findings]
normalized.sort(key=finding_sort_key)
counts = severity_counts(normalized)
blockers = [finding for finding in normalized if is_deploy_blocking(finding)]
Expand Down Expand Up @@ -412,6 +412,40 @@ def _finding_detail(index: int, finding: dict[str, Any]) -> list[str]:
]


_PROSE_FIELDS = ("message", "remediation", "verification")


def _md_prose(value: Any) -> str:
"""HTML-neutralize free text so a hostile finding message can't inject
markup when a generated markdown report is later rendered as HTML."""
return (
str(value)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)


def _md_fence(value: Any) -> str:
"""Prevent a snippet from breaking out of its ``` code fence."""
return str(value).replace("```", "`\u200b``")


def _sanitize_for_markdown(finding: dict[str, Any]) -> dict[str, Any]:
"""Return a render-safe copy: prose fields HTML-escaped, snippet fence-safe.

rule_id/category/context/severity are constrained identifiers used in gate
logic and are left untouched.
"""
safe = dict(finding)
for field in _PROSE_FIELDS:
if field in safe:
safe[field] = _md_prose(safe[field])
if "snippet" in safe:
safe["snippet"] = _md_fence(safe["snippet"])
return safe


def _short_title(message: str, max_len: int = 84) -> str:
"""Return a compact report heading derived from a finding message."""
title = message.split(".", 1)[0].strip() or "Security finding"
Expand All @@ -432,7 +466,7 @@ def _prepare_report(
]:
"""Normalize findings and compute shared report metadata."""
context = context or ReportContext()
normalized = [normalize_finding(finding) for finding in findings]
normalized = [_sanitize_for_markdown(normalize_finding(finding)) for finding in findings]
normalized.sort(key=finding_sort_key)
counts = severity_counts(normalized)
blockers = [finding for finding in normalized if is_deploy_blocking(finding)]
Expand Down
48 changes: 48 additions & 0 deletions tests/test_report_sanitization.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""Reports must neutralize hostile finding content (stored-XSS via markdown)."""

import pytest

from appguardrail_core.reports import render_report, supported_report_types

HOSTILE = {
"severity": "HIGH",
"rule_id": "x",
"message": "<script>alert(1)</script> <img src=x onerror=alert(2)>",
"remediation": "run <b>evil</b>",
"verification": "check <iframe>",
"file": "a.ts",
"line": 1,
"context": "app-code",
"snippet": "before ``` break out ``` after",
"owasp": ["A03:2021"],
}


@pytest.mark.parametrize("report_type", supported_report_types())
def test_no_raw_html_in_any_report(report_type):
report = render_report(report_type, [HOSTILE])
# active markup must be escaped, not passed through
assert "<script>" not in report
assert "onerror=" not in report or "&gt;" in report # tag broken up
assert "<iframe>" not in report
assert "<b>evil</b>" not in report


def test_escapes_to_entities():
report = render_report("buyer-diligence", [HOSTILE])
assert "&lt;script&gt;" in report
assert "&lt;b&gt;evil&lt;/b&gt;" in report


def test_snippet_cannot_break_code_fence():
report = render_report("buyer-diligence", [HOSTILE])
# the raw triple-backtick from the snippet must be neutralized (zero-width
# joiner inserted) so it can't close the ```text fence early
assert "``` break out ```" not in report


def test_benign_message_unchanged():
benign = {**HOSTILE, "message": "Hardcoded secret detected", "remediation": "move to env",
"verification": "rerun", "snippet": "const k = 1"}
report = render_report("founder-friendly", [benign])
assert "Hardcoded secret detected" in report # no over-escaping