diff --git a/README.md b/README.md index f13f2ab..7fb97a5 100644 --- a/README.md +++ b/README.md @@ -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`로 억제 가능 이 도구는 권한이 있는 분석 대상에서 리버스 엔지니어링, 보안 검증, 펜테스트, 동적 계측 자동화를 돕기 위한 용도입니다. @@ -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 파싱 diff --git a/sample/tests/test_audit_html_report.py b/sample/tests/test_audit_html_report.py index 3be7897..fd83267 100644 --- a/sample/tests/test_audit_html_report.py +++ b/sample/tests/test_audit_html_report.py @@ -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() diff --git a/sample/tests/test_code_audit.py b/sample/tests/test_code_audit.py index d01fd5a..db225c1 100644 --- a/sample/tests/test_code_audit.py +++ b/sample/tests/test_code_audit.py @@ -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: @@ -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() diff --git a/sample/tests/test_poc_generator.py b/sample/tests/test_poc_generator.py index 06d4271..944ba62 100644 --- a/sample/tests/test_poc_generator.py +++ b/sample/tests/test_poc_generator.py @@ -35,6 +35,7 @@ from venomhook.poc_generator import ( PER_CODE_RULE_BUILDERS, PER_RULE_BUILDERS, + dedup_pocs_by_template, format_pocs_text, generate_code_pocs, generate_pocs, @@ -202,7 +203,11 @@ def test_adb_backup_recipe_emitted(self) -> None: class ExportedNoPermissionBuilderTests(unittest.TestCase): - def test_per_action_artifact_for_activity(self) -> None: + def test_actions_grouped_into_single_artifact_per_component(self) -> None: + """Phase 11-2: one adb PoC per component with every action in + commands (was: N adb PoCs for N actions). Plus one Frida + observer per component, unchanged. + """ comp = _comp( type="activity", name="com.x.PublicAct", exported=True, exported_declared=True, @@ -211,15 +216,32 @@ def test_per_action_artifact_for_activity(self) -> None: ) meta = _meta(components=[comp]) arts = generate_pocs(meta, audit_manifest(meta)) - # one ADB artifact per action + one Frida observer for the component - self.assertEqual(len(arts), 3) adb_arts = [a for a in arts if a.kind == "adb"] frida_arts = [a for a in arts if a.kind == "frida"] - self.assertEqual(len(adb_arts), 2) + self.assertEqual(len(adb_arts), 1, f"got {len(adb_arts)} adb PoCs, expected 1") self.assertEqual(len(frida_arts), 1) - for a in adb_arts: - self.assertEqual(a.component, "com.x.PublicAct") - self.assertTrue(any("am start" in c for c in a.commands)) + adb = adb_arts[0] + self.assertEqual(adb.component, "com.x.PublicAct") + # Both actions present in commands of the single artifact + joined = "\n".join(adb.commands) + self.assertIn("android.intent.action.VIEW", joined) + self.assertIn("android.intent.action.SEND", joined) + self.assertIn("2 actions", adb.title) + + def test_single_action_no_action_count_suffix(self) -> None: + """When the component has only one action the title stays clean.""" + comp = _comp( + type="activity", name="com.x.OneAct", + exported=True, exported_declared=True, + intent_actions=["android.intent.action.MAIN"], + ) + meta = _meta(components=[comp]) + arts = generate_pocs(meta, audit_manifest(meta)) + adb = next(a for a in arts if a.kind == "adb") + self.assertNotIn("actions", adb.title) + self.assertEqual(adb.commands.count( + "# 1 intent-filter actions — 각 라인을 차례로 시도" + ), 0) def test_frida_observer_for_activity_hooks_oncreate(self) -> None: comp = _comp( @@ -712,5 +734,108 @@ def test_render_includes_severity_rule_and_commands(self) -> None: self.assertIn("adb forward", out) +class DedupPocsByTemplateTests(unittest.TestCase): + """Phase 11-3: same-shape PoCs collapse, applies_to records targets.""" + + def _p(self, *, rule="MANIFEST-004", kind="adb", title="t", + commands=None, component=None, severity="high") -> PoCArtifact: + return PoCArtifact( + rule_id=rule, title=title, severity=severity, kind=kind, + package_name="com.demo", component=component, + commands=list(commands or []), + ) + + def test_empty_input(self): + self.assertEqual(dedup_pocs_by_template([]), []) + + def test_unique_pocs_passthrough(self): + a = self._p(commands=["am start -n com.demo/.A"]) + b = self._p(rule="MANIFEST-005", commands=["adb backup"]) + out = dedup_pocs_by_template([a, b]) + self.assertEqual(len(out), 2) + for p in out: + self.assertEqual(p.applies_to, []) + + def test_same_shape_different_component_collapses(self): + """Two adb am-start commands targeting different com.demo classes + must dedup to one representative + applies_to listing the second. + """ + a = self._p( + title="외부 노출 액티비티 'com.demo.A' 호출", + component="com.demo.A", + commands=["adb shell am start -n com.demo/com.demo.A"], + ) + b = self._p( + title="외부 노출 액티비티 'com.demo.B' 호출", + component="com.demo.B", + commands=["adb shell am start -n com.demo/com.demo.B"], + ) + out = dedup_pocs_by_template([a, b]) + self.assertEqual(len(out), 1) + self.assertEqual(out[0].component, "com.demo.A") + self.assertEqual(out[0].applies_to, ["com.demo.B"]) + + def test_three_components_collapse_to_one(self): + ps = [ + self._p( + title=f"외부 노출 액티비티 '{cls}' 호출", + component=cls, + commands=[f"adb shell am start -n com.demo/{cls}"], + ) + for cls in ("com.demo.A", "com.demo.B", "com.demo.C") + ] + out = dedup_pocs_by_template(ps) + self.assertEqual(len(out), 1) + self.assertEqual(set(out[0].applies_to), {"com.demo.B", "com.demo.C"}) + + def test_different_severity_not_collapsed(self): + a = self._p(severity="high", component="com.demo.A", + commands=["adb shell am start -n com.demo/com.demo.A"]) + b = self._p(severity="medium", component="com.demo.B", + commands=["adb shell am start -n com.demo/com.demo.B"]) + out = dedup_pocs_by_template([a, b]) + self.assertEqual(len(out), 2) + + def test_different_kind_not_collapsed(self): + a = self._p(kind="adb", component="com.demo.A", + commands=["adb shell am start -n com.demo/com.demo.A"]) + b = self._p(kind="frida", component="com.demo.A", + commands=["Java.use('com.demo.A')"]) + out = dedup_pocs_by_template([a, b]) + self.assertEqual(len(out), 2) + + def test_deeplink_uri_normalized_for_dedup(self): + """kakaotalk://x/foo vs kakaotalk://x/bar → same template, dedup.""" + a = self._p( + title="Deeplink 진입: kakaotalk://foo", + component="com.demo.SchemeBridgeA", + commands=[ + "adb shell am start -W -a android.intent.action.VIEW " + "-d 'kakaotalk://foo/path1'" + ], + ) + b = self._p( + title="Deeplink 진입: kakaotalk://bar", + component="com.demo.SchemeBridgeB", + commands=[ + "adb shell am start -W -a android.intent.action.VIEW " + "-d 'kakaotalk://bar/path2'" + ], + ) + out = dedup_pocs_by_template([a, b]) + self.assertEqual(len(out), 1) + self.assertIn("com.demo.SchemeBridgeB", out[0].applies_to) + + def test_applies_to_round_trips_via_dict(self): + p = self._p(component="com.demo.A") + p.applies_to = ["com.demo.B", "com.demo.C"] + rt = PoCArtifact.from_dict(p.to_dict()) + self.assertEqual(rt.applies_to, ["com.demo.B", "com.demo.C"]) + + def test_empty_applies_to_omitted_from_json(self): + p = self._p(component="com.demo.A") + self.assertNotIn("applies_to", p.to_dict()) + + if __name__ == "__main__": unittest.main() diff --git a/sample/tests/test_smali_audit.py b/sample/tests/test_smali_audit.py index e145411..0076de2 100644 --- a/sample/tests/test_smali_audit.py +++ b/sample/tests/test_smali_audit.py @@ -284,12 +284,16 @@ def test_only_smali(self): merged = merge_code_reports(None, smali) self.assertIs(merged, smali) - def test_java_wins_on_overlap(self): + def test_java_wins_on_overlap_with_smali_fold(self): + """Phase 11-5: java tier survives, smali primary line folds into + java's occurrences as additional evidence. + """ java = CodeAuditReport( package_name="com.x", findings=[CodeFinding( rule_id="CODE-001", title="java", severity="medium", file="x.java", class_fqn="com.x.A", + line_no=10, line_text="String url = \"http://...\"", )], files_scanned=1, @@ -299,18 +303,84 @@ def test_java_wins_on_overlap(self): findings=[CodeFinding( rule_id="CODE-001", title="smali", severity="medium", file="x.smali", class_fqn="com.x.A", evidence_tier="smali", + line_no=25, line_text="const-string v0, \"http://...\"", )], files_scanned=2, ) merged = merge_code_reports(java, smali) self.assertEqual(len(merged.findings), 1) - # java tier survived - self.assertEqual(merged.findings[0].evidence_tier, "java") - self.assertEqual(merged.findings[0].title, "java") + # java tier survived as the representative + rep = merged.findings[0] + self.assertEqual(rep.evidence_tier, "java") + self.assertEqual(rep.title, "java") + # smali tier folded in as occurrence (NEW in Phase 11-5) + self.assertEqual(len(rep.occurrences), 1) + self.assertEqual(rep.occurrences[0].evidence_tier, "smali") + self.assertEqual(rep.occurrences[0].line_no, 25) # files_scanned is sum self.assertEqual(merged.files_scanned, 3) + def test_smali_occurrences_also_fold_into_java_rep(self): + """If smali tier itself dedup-grouped multiple lines into one + finding + occurrences, the WHOLE smali bundle (primary + occs) + attaches to the java representative. + """ + from venomhook.models import CodeOccurrence + java = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-001", title="java", severity="medium", + file="x.java", class_fqn="com.x.A", line_no=10, + )], + files_scanned=1, + ) + smali_finding = CodeFinding( + rule_id="CODE-001", title="smali", severity="medium", + file="x.smali", class_fqn="com.x.A", evidence_tier="smali", + line_no=25, + occurrences=[ + CodeOccurrence(line_no=30, line_text="b", evidence_tier="smali"), + CodeOccurrence(line_no=40, line_text="c", evidence_tier="smali"), + ], + ) + smali = CodeAuditReport( + package_name="com.x", + findings=[smali_finding], + files_scanned=2, + ) + merged = merge_code_reports(java, smali) + rep = merged.findings[0] + # 1 (smali primary) + 2 (smali's own occurrences) = 3 smali occs + # on the java representative + self.assertEqual(len(rep.occurrences), 3) + self.assertEqual({o.evidence_tier for o in rep.occurrences}, {"smali"}) + + def test_severity_difference_keeps_smali_separate(self): + """High and medium severity findings on the same class stay as + two cards (mirrors the dedup_findings_by_class semantics). + """ + java = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-002", title="java-high", severity="high", + file="x.java", class_fqn="com.x.W", line_no=10, + )], + ) + smali = CodeAuditReport( + package_name="com.x", + findings=[CodeFinding( + rule_id="CODE-002", title="smali-medium", severity="medium", + file="x.smali", class_fqn="com.x.W", evidence_tier="smali", + line_no=25, + )], + ) + merged = merge_code_reports(java, smali) + self.assertEqual(len(merged.findings), 2) + # No fold — different severities are not the same finding + for f in merged.findings: + self.assertEqual(f.occurrences, []) + def test_distinct_findings_concatenate(self): java = CodeAuditReport( package_name="com.x", diff --git a/src/venomhook/audit_html_report.py b/src/venomhook/audit_html_report.py index f1ee79b..c0d17f0 100644 --- a/src/venomhook/audit_html_report.py +++ b/src/venomhook/audit_html_report.py @@ -330,12 +330,28 @@ def _render_poc(link: _PocLink) -> str: f'{escape(Path(link.href).name)}' ) + # Phase 11-3: applies_to chip in summary so the operator knows this + # single .sh covers multiple components / classes without unfolding. + applies_chip = "" + if a.applies_to: + applies_chip = ( + f'' + f'+{len(a.applies_to)} 적용' + ) body_parts: list[str] = [] if a.description: body_parts.append(f"
{escape(a.description)}
") if a.commands: cmd_text = "\n".join(a.commands) body_parts.append(f"{escape(cmd_text)}")
+ # Phase 11-3: explicit applies_to list inside the body so operators
+ # can copy the additional targets straight out of the report.
+ if a.applies_to:
+ items = "".join(f"{escape(t)}다음 컴포넌트에도 동일 템플릿 적용 가능:' + f'
예상 결과: {escape(a.expected_evidence)}
" @@ -354,6 +370,7 @@ def _render_poc(link: _PocLink) -> str: f'{escape(finding.line_text[:240])}{escape((o.line_text or "")[:200])}'
+ if o.line_text else ""
+ )
+ tier_part = (
+ f' [{escape(o.evidence_tier)}]'
+ if o.evidence_tier and o.evidence_tier != finding.evidence_tier else ""
+ )
+ occ_rows.append(
+ f'