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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ PE, ELF, Mach-O를 같은 데이터 모델로 다룹니다. Android APK 분석
- **Java tier** — jadx 디컴파일 결과 위에서 6 룰 (평문 HTTP, WebView setJavaScriptEnabled / addJavascriptInterface, 약한 Cipher / 해시, 평문 자격증명 로그, 외부 저장소 사용, MODE_WORLD_READABLE/WRITEABLE)
- **Smali tier (NEW)** — jadx가 타임아웃 / 실패해도 apktool이 생성한 smali에서 4 룰(CODE-001/003/005/006)을 매칭. 패킹·난독화 APK에도 코드 결함 산출 보장
- **공격면 추출** — deeplink / 데이터 스킴 / 카테고리, intent-filter 구조, JNI bridge correlation, .so 문자열 카테고리화 (URL / 경로 / 쉘 / crypto / 자격증명 단서 / SQL)
- **PoC 자동 생성** — adb 명령, Frida 후킹, mitmproxy 가로채기, logcat grep 레시피를 .sh / .frida.js / .md 번들로 export
- **자체 포함 HTML 보고서** — 심각도 색상 카드, MASVS 카테고리 그룹핑, 코드 단서 인용, on-disk PoC 링크 — 외부 자산 / JS 의존 없음
- **PoC 자동 생성 + dedup** — adb 명령, Frida 후킹, mitmproxy 가로채기, logcat grep 레시피를 .sh / .frida.js / .md 번들로 export. 동일 템플릿 PoC는 자동으로 1개 + `applies_to` 컴포넌트 리스트로 압축 (KakaoTalk-급 APK에서 190→17 PoCs로 −91% 압축)
- **결과 정규화** — 같은 클래스 안의 동일 룰 반복 발화는 1개의 finding + `occurrences` 펼침으로 압축. 펜테스트 보고서 가독성↑
- **자체 포함 HTML 보고서** — 심각도 색상 카드, MASVS 카테고리 그룹핑, 코드 단서 인용, occurrence 펼침 + applies_to 칩, on-disk PoC 링크 — 외부 자산 / JS 의존 없음
- **10단계 라이브 진행 출력** — 모든 단계가 stderr에 `[N/10] step ...` 형식으로 진행 표시. `--quiet`로 억제 가능

이 도구는 권한이 있는 분석 대상에서 리버스 엔지니어링, 보안 검증, 펜테스트, 동적 계측 자동화를 돕기 위한 용도입니다.
Expand Down Expand Up @@ -177,7 +178,7 @@ venomhook/
├── ghidra_scripts/
├── sample/
│ ├── examples/
│ └── tests/ # 834+ 단위 테스트
│ └── tests/ # 864+ 단위 테스트
├── setup/
├── src/venomhook/
│ ├── apk_decoder.py # Manifest + apktool.yml + NSC + intent-filter 파싱
Expand Down
76 changes: 76 additions & 0 deletions sample/tests/test_audit_html_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -518,5 +518,81 @@ def test_code_findings_contribute_to_taxonomy(self) -> None:
self.assertIn(">CODE-003<", html)


class OccurrencesAndAppliesToRenderTests(unittest.TestCase):
"""Phase 11-1 / 11-3 HTML: occurrence badge + applies_to chip."""

def test_code_finding_occurrence_count_badge_renders(self) -> None:
from venomhook.models import CodeOccurrence
cf = CodeFinding(
rule_id="CODE-001", title="hardcoded HTTP", severity="high",
file="com/demo/Net.java", line_no=42,
line_text='String url = "http://a";',
class_fqn="com.demo.Net",
detail="..", remediation="use https",
occurrences=[
CodeOccurrence(line_no=50, line_text='"http://b";'),
CodeOccurrence(line_no=58, line_text='"http://c";',
evidence_tier="smali"),
],
)
analysis = _analysis_with_code_findings(code_findings=[cf])
html = render_audit_html(analysis)
# x3 = primary + 2 occurrences
self.assertIn("×3건", html)
# Collapsible additional evidence
self.assertIn("동일 클래스 내 추가 단서", html)
self.assertIn("L50", html)
self.assertIn("L58", html)
# Smali tier label on the divergent occurrence
self.assertIn("[smali]", html)

def test_code_finding_no_badge_when_single_occurrence(self) -> None:
cf = CodeFinding(
rule_id="CODE-001", title="t", severity="high",
file="com/demo/A.java", line_no=10, class_fqn="com.demo.A",
)
analysis = _analysis_with_code_findings(code_findings=[cf])
html = render_audit_html(analysis)
self.assertNotIn("×1건", html)
self.assertNotIn("추가 단서", html)

def test_smali_tier_label_renders(self) -> None:
cf = CodeFinding(
rule_id="CODE-003", title="weak crypto", severity="high",
file="com/demo/C.smali", line_no=12, class_fqn="com.demo.C",
evidence_tier="smali",
)
analysis = _analysis_with_code_findings(code_findings=[cf])
html = render_audit_html(analysis)
self.assertIn("tier: smali", html)

def test_poc_applies_to_chip_renders(self) -> None:
"""PoC card summary should show '+N 적용' when applies_to non-empty."""
from venomhook.poc_generator import PER_RULE_BUILDERS
# Generate via the actual builder so we get the right shape, then
# mutate applies_to like the dedup helper would.
meta = _stub_app()
findings = [
ManifestFinding(
rule_id="MANIFEST-004", title="외부 노출 액티비티",
severity="high",
detail="d", remediation="r",
component="com.demo.A",
references=[],
),
]
artifacts = PER_RULE_BUILDERS["MANIFEST-004"](meta, findings[0])
artifacts[0].applies_to = ["com.demo.B", "com.demo.C"]

from venomhook.android_pipeline import AndroidAnalysis
analysis = _stub_analysis()
analysis.pocs = artifacts
analysis.audit_report.findings = findings
html = render_audit_html(analysis)
self.assertIn("+2 적용", html)
self.assertIn("com.demo.B", html)
self.assertIn("com.demo.C", html)


if __name__ == "__main__":
unittest.main()
116 changes: 115 additions & 1 deletion sample/tests/test_code_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "src"))

from venomhook.code_audit import (
dedup_findings_by_class, # noqa: F401 re-exported for tests below
)
from venomhook.code_audit import (
DEFAULT_THIRD_PARTY_PREFIXES,
audit_code,
iter_app_java_files,
)
from venomhook.models import AndroidAppMeta, CodeAuditReport, CodeFinding
from venomhook.models import (
AndroidAppMeta,
CodeAuditReport,
CodeFinding,
CodeOccurrence,
)


def _meta(package_name: str = "com.example.app") -> AndroidAppMeta:
Expand Down Expand Up @@ -569,5 +577,111 @@ def test_roundtrip_preserves_findings_and_count(self) -> None:
self.assertEqual(roundtripped.files_scanned, 42)


class DedupFindingsByClassTests(unittest.TestCase):
"""Phase 11-1: collapse same (rule_id, class_fqn) into representative+occurrences."""

def _f(self, rule_id="CODE-001", cls="com.x.A", line=1, file="x.java",
text="t", tier="java") -> CodeFinding:
return CodeFinding(
rule_id=rule_id, title="t", severity="medium",
file=file, line_no=line, line_text=text,
class_fqn=cls, evidence_tier=tier,
)

def test_empty_input(self):
self.assertEqual(dedup_findings_by_class([]), [])

def test_distinct_classes_kept_separate(self):
f1 = self._f(cls="com.x.A")
f2 = self._f(cls="com.x.B")
out = dedup_findings_by_class([f1, f2])
self.assertEqual(len(out), 2)
self.assertEqual(out[0].occurrences, [])
self.assertEqual(out[1].occurrences, [])

def test_distinct_rules_kept_separate(self):
f1 = self._f(rule_id="CODE-001", cls="com.x.A")
f2 = self._f(rule_id="CODE-003", cls="com.x.A")
out = dedup_findings_by_class([f1, f2])
self.assertEqual(len(out), 2)

def test_same_class_same_rule_collapses(self):
f1 = self._f(line=10, text="http://a.test")
f2 = self._f(line=20, text="http://b.test")
f3 = self._f(line=30, text="http://c.test")
out = dedup_findings_by_class([f1, f2, f3])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].line_no, 10) # representative is first
self.assertEqual(len(out[0].occurrences), 2)
self.assertEqual([o.line_no for o in out[0].occurrences], [20, 30])
self.assertEqual(out[0].occurrences[0].line_text, "http://b.test")
# occurrence_count = primary + extras
self.assertEqual(out[0].occurrence_count, 3)

def test_same_class_same_line_not_duplicated(self):
"""Two rules firing on the same line still create one occurrence,
and a single rule firing twice on the same line (defensive case)
is folded down to the primary only.
"""
f1 = self._f(line=10, text="x")
f2 = self._f(line=10, text="x") # exact duplicate
out = dedup_findings_by_class([f1, f2])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].occurrences, [])

def test_empty_class_fqn_falls_back_to_file(self):
f1 = self._f(cls="", file="A.java", line=10)
f2 = self._f(cls="", file="A.java", line=20)
f3 = self._f(cls="", file="B.java", line=10)
out = dedup_findings_by_class([f1, f2, f3])
# A.java group + B.java group
self.assertEqual(len(out), 2)
a_group = next(f for f in out if f.file == "A.java")
self.assertEqual(len(a_group.occurrences), 1)

def test_evidence_tier_preserved_in_occurrence(self):
f1 = self._f(line=10, tier="java")
f2 = self._f(line=20, tier="smali")
out = dedup_findings_by_class([f1, f2])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].evidence_tier, "java")
self.assertEqual(out[0].occurrences[0].evidence_tier, "smali")

def test_codefinding_to_from_dict_round_trips_occurrences(self):
f = self._f(line=10)
f.occurrences.append(CodeOccurrence(line_no=20, line_text="more"))
f.occurrences.append(CodeOccurrence(line_no=30, line_text="more2",
evidence_tier="smali"))
rt = CodeFinding.from_dict(f.to_dict())
self.assertEqual(len(rt.occurrences), 2)
self.assertEqual(rt.occurrences[0].line_no, 20)
self.assertEqual(rt.occurrences[1].evidence_tier, "smali")

def test_audit_code_dedups_in_real_pipeline(self):
"""audit_code() at module level returns the deduped report."""
import tempfile
with tempfile.TemporaryDirectory() as td:
src = Path(td)
# Two http URLs in one class → after audit_code, 1 finding +
# 1 occurrence rather than 2 separate findings.
(src / "com" / "demo" / "app").mkdir(parents=True)
(src / "com" / "demo" / "app" / "Net.java").write_text(
"package com.demo.app;\n"
"class Net {\n"
" String a = \"http://a.test/\";\n"
" String b = \"http://b.test/\";\n"
"}\n"
)
report = audit_code(
src, AndroidAppMeta(
package_name="com.demo.app",
application_class=None, permissions=[], components=[],
),
)
http_findings = [f for f in report.findings if f.rule_id == "CODE-001"]
self.assertEqual(len(http_findings), 1)
self.assertEqual(http_findings[0].occurrence_count, 2)


if __name__ == "__main__":
unittest.main()
Loading
Loading