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)}
  • " for t in a.applies_to) + body_parts.append( + '

    다음 컴포넌트에도 동일 템플릿 적용 가능:' + f'

    ' + ) if a.expected_evidence: body_parts.append( f"

    예상 결과: {escape(a.expected_evidence)}

    " @@ -354,6 +370,7 @@ def _render_poc(link: _PocLink) -> str: f'' f'{kind_label}' f'{title}' + f'{applies_chip}' f'{file_link}' f'' f'
    {body_html}
    ' @@ -441,6 +458,22 @@ def _render_code_finding_card( if finding.file: loc = f"{finding.file}:{finding.line_no}" if finding.line_no else finding.file location = f'{escape(loc)}' + # Phase 11-1: occurrence count badge. 1 = unique, N>1 = "+ N-1 more lines + # in the same class". Tier label sits beside it so smali-tier findings + # carry their evidence form prominently. + badges: list[str] = [] + if finding.occurrence_count > 1: + badges.append( + f'' + f'×{finding.occurrence_count}건' + ) + if finding.evidence_tier and finding.evidence_tier != "java": + badges.append( + f'' + f'tier: {escape(finding.evidence_tier)}' + ) + badge_html = " ".join(badges) + body_parts: list[str] = [] if finding.detail: body_parts.append(f'
    {escape(finding.detail)}
    ') @@ -449,6 +482,34 @@ def _render_code_finding_card( '
    코드 단서: ' f'{escape(finding.line_text[:240])}
    ' ) + # Phase 11-1: additional occurrences as a collapsible
    . + if finding.occurrences: + occ_rows = [] + for o in finding.occurrences: + file_part = ( + f' {escape(o.file)}' + if o.file else "" + ) + text_part = ( + f' {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'
  • L{o.line_no}{file_part}{tier_part}{text_part}
  • ' + ) + body_parts.append( + '
    ' + f'동일 클래스 내 추가 단서 ' + f'{len(finding.occurrences)}건 펼치기' + f'
    " + ) if finding.remediation: body_parts.append( '
    대응 방안: ' @@ -477,6 +538,7 @@ def _render_code_finding_card( f'{rule_id}' f'

    {title}

    ' f'{location}' + f"{badge_html}" f"" f"{desc}" f"{refs}" diff --git a/src/venomhook/code_audit.py b/src/venomhook/code_audit.py index 2780b35..40131a9 100644 --- a/src/venomhook/code_audit.py +++ b/src/venomhook/code_audit.py @@ -49,6 +49,7 @@ AndroidAppMeta, CodeAuditReport, CodeFinding, + CodeOccurrence, ) @@ -57,6 +58,7 @@ "iter_app_java_files", "RULES", "DEFAULT_THIRD_PARTY_PREFIXES", + "dedup_findings_by_class", ] @@ -614,6 +616,57 @@ def _check_mode_world( ] +def dedup_findings_by_class( + findings: Iterable[CodeFinding], +) -> list[CodeFinding]: + """Phase 11-1: collapse findings sharing (rule_id, class_fqn, severity) + into one representative + occurrences list. + + Same class, same rule, *same severity*, multiple matching lines used + to produce N separate cards (KakaoTalk's ``com.caverock.androidsvg.i`` + fired CODE-001 12 times all at high). Operators reading the report + scrolled past the same problem repeated; this helper keeps the first + occurrence as the representative finding and folds the rest into + ``CodeFinding.occurrences``. + + The key includes severity so a single rule that emits multiple + severity variants (CODE-002's medium ``setJavaScriptEnabled(true)`` + vs high ``addJavascriptInterface``) stays as two separate findings — + operators triaging by severity would lose the high signal otherwise. + + Grouping key prefers ``class_fqn`` (recovered from package + + Class.java naming); falls back to ``file`` for findings whose + enclosing class can't be inferred (default-package code, header + comments, etc.). + + Stable: representatives appear in the order their first occurrence + came from the rules. Each occurrence retains its (line_no, + line_text, file, evidence_tier) so HTML can render a "주요 + + N개 추가" expandable section. + """ + out: list[CodeFinding] = [] + index: dict[tuple[str, str, str], int] = {} + for f in findings: + key_class = f.class_fqn or f.file + key = (f.rule_id, key_class, f.severity) + slot = index.get(key) + if slot is None: + index[key] = len(out) + out.append(f) + continue + existing = out[slot] + # Don't duplicate the representative line itself. + if f.line_no == existing.line_no and f.file == existing.file: + continue + existing.occurrences.append(CodeOccurrence( + line_no=f.line_no, + line_text=f.line_text, + file=f.file if f.file != existing.file else "", + evidence_tier=f.evidence_tier, + )) + return out + + def audit_code( sources_dir: Path | str, meta: Optional[AndroidAppMeta] = None, @@ -626,6 +679,11 @@ def audit_code( on repeat calls. Returns an empty report (with files_scanned=0) when the sources directory doesn't exist; callers that care about the distinction between "no findings" and "no scan" can check that. + + Phase 11-1: findings sharing (rule_id, class_fqn) are deduplicated + via ``dedup_findings_by_class`` before being returned. Each + representative carries the extra matches in ``occurrences`` so no + evidence is lost while the report stays compact. """ src = Path(sources_dir) files = iter_app_java_files( @@ -636,6 +694,7 @@ def audit_code( findings: list[CodeFinding] = [] for rule in RULES: findings.extend(rule(files, meta, src)) + findings = dedup_findings_by_class(findings) return CodeAuditReport( package_name=meta.package_name if meta else "", findings=findings, diff --git a/src/venomhook/models.py b/src/venomhook/models.py index 7a884a2..86f9ac4 100644 --- a/src/venomhook/models.py +++ b/src/venomhook/models.py @@ -748,6 +748,43 @@ def to_dict(self) -> dict[str, Any]: } +@dataclass +class CodeOccurrence: + """One additional line where the same (rule_id, class_fqn) fired. + + Phase 11-1. Used to compress duplicate findings inside one class — + the audit engine still scans every line, but the report shows one + representative finding per (rule, class) and rolls the rest into + ``CodeFinding.occurrences`` so the operator sees "URL hardcoded + here, plus 11 more lines in this class" instead of 12 separate + cards. + """ + + line_no: int + line_text: str = "" + file: str = "" + evidence_tier: str = "java" # follows the parent finding's tier by default + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "CodeOccurrence": + return cls( + line_no=int(data.get("line_no", 0)), + line_text=data.get("line_text", ""), + file=data.get("file", ""), + evidence_tier=data.get("evidence_tier", "java"), + ) + + def to_dict(self) -> dict[str, Any]: + result: dict[str, Any] = {"line_no": self.line_no} + if self.line_text: + result["line_text"] = self.line_text + if self.file: + result["file"] = self.file + if self.evidence_tier and self.evidence_tier != "java": + result["evidence_tier"] = self.evidence_tier + return result + + @dataclass class CodeFinding: """A single code-level rule violation found in jadx-decompiled Java sources. @@ -780,6 +817,18 @@ class CodeFinding: # entirely. HTML / JSON consumers surface the tier next to each # finding so the reader knows the evidence form. evidence_tier: str = "java" + # Phase 11-1: additional matches of the same (rule_id, class_fqn) in + # the same class — kept as references to the line that fired so the + # operator sees the spread without 12 duplicated cards. The + # representative finding lives in the top-level fields above; this + # list carries the rest (skip the first match — it's already in + # the primary record). + occurrences: list[CodeOccurrence] = field(default_factory=list) + + @property + def occurrence_count(self) -> int: + """Total matches (primary + extra occurrences). Useful for HTML.""" + return 1 + len(self.occurrences) @classmethod def from_dict(cls, data: dict[str, Any]) -> "CodeFinding": @@ -795,6 +844,10 @@ def from_dict(cls, data: dict[str, Any]) -> "CodeFinding": remediation=data.get("remediation", ""), references=list(data.get("references", [])), evidence_tier=data.get("evidence_tier", "java"), + occurrences=[ + CodeOccurrence.from_dict(o) + for o in data.get("occurrences", []) + ], ) def to_dict(self) -> dict[str, Any]: @@ -819,6 +872,8 @@ def to_dict(self) -> dict[str, Any]: # Only serialize non-default tier to keep older JSON dumps clean. if self.evidence_tier and self.evidence_tier != "java": result["evidence_tier"] = self.evidence_tier + if self.occurrences: + result["occurrences"] = [o.to_dict() for o in self.occurrences] return result @@ -908,6 +963,12 @@ class PoCArtifact: expected_evidence: str = "" notes: str = "" references: list[str] = field(default_factory=list) + # Phase 11-3: when multiple PoCs share the same (rule_id, kind, + # template shape) they collapse into one artifact via + # ``poc_generator.dedup_pocs_by_template`` and ``applies_to`` + # records the components (or class FQNs for code-tier PoCs) the + # remaining template covers. Empty list when the PoC is unique. + applies_to: list[str] = field(default_factory=list) @classmethod def from_dict(cls, data: dict[str, Any]) -> "PoCArtifact": @@ -923,6 +984,7 @@ def from_dict(cls, data: dict[str, Any]) -> "PoCArtifact": expected_evidence=data.get("expected_evidence", ""), notes=data.get("notes", ""), references=list(data.get("references", [])), + applies_to=list(data.get("applies_to", [])), ) def to_dict(self) -> dict[str, Any]: @@ -945,4 +1007,6 @@ def to_dict(self) -> dict[str, Any]: result["notes"] = self.notes if self.references: result["references"] = list(self.references) + if self.applies_to: + result["applies_to"] = list(self.applies_to) return result diff --git a/src/venomhook/poc_generator.py b/src/venomhook/poc_generator.py index a842ab5..4c8c45f 100644 --- a/src/venomhook/poc_generator.py +++ b/src/venomhook/poc_generator.py @@ -36,7 +36,7 @@ import json import shlex -from typing import Callable +from typing import Callable, Iterable from venomhook.models import ( AndroidAppMeta, @@ -53,6 +53,7 @@ "generate_pocs", "generate_code_pocs", "format_pocs_text", + "dedup_pocs_by_template", "PER_RULE_BUILDERS", "PER_CODE_RULE_BUILDERS", ] @@ -356,36 +357,54 @@ def _build_exported_no_permission( "provider": "프로바이더", }.get(component.type, component.type) + # Phase 11-2: KakaoTalk's RecentExcludeIntentFilterActivity carries 43 + # intent-filter actions and we used to emit 43 separate PoCArtifacts, + # each running 1 `am` command. Operators saw the same template repeated + # 43 times. Consolidate into one PoC per component whose .sh tries every + # action sequentially so a single artifact covers the full attack + # surface for that component. actions = component.intent_actions or [None] artifacts: list[PoCArtifact] = [] - for action in actions: - cmd = _am_command_for(component, pkg, action) - artifacts.append(PoCArtifact( - rule_id=finding.rule_id, - title=f"외부 노출 {type_korean} '{component.name}' 호출" - + (f" (action={action})" if action else ""), - severity=finding.severity, - kind="adb", - package_name=pkg, - component=component.name, - description=( - f"외부에 노출된 {type_korean}가 권한 없이 외부 인텐트를 수용합니다. " - "본 레시피는 직접 인텐트를 전송합니다 — 실제 공격에서는 " - "onCreate/onReceive 안의 신뢰 결정 지점으로 전달되는 extras를 " - "조작해 함께 보냅니다." - ), - commands=[ - cmd, - "# 파싱 로직을 탐색하려면 extras를 추가, 예:", - f"# {cmd} --es payload \"$(printf 'A%.0s' {{1..1024}})\"", - ], - expected_evidence=( - "컴포넌트가 호출자 shell uid(2000)로 시작/수신됩니다 (앱 uid가 " - "아님). logcat에 onCreate/onStartCommand/onReceive 진입 로그가 " - "남습니다." - ), - references=list(finding.references), - )) + primary_cmd = _am_command_for(component, pkg, actions[0]) + commands: list[str] = [] + if len(actions) == 1: + commands.append(primary_cmd) + else: + commands.append( + f"# {len(actions)} intent-filter actions — 각 라인을 차례로 시도" + ) + for action in actions: + commands.append(_am_command_for(component, pkg, action)) + commands.extend([ + "", + "# 파싱 로직을 탐색하려면 extras를 추가, 예:", + f"# {primary_cmd} --es payload \"$(printf 'A%.0s' {{1..1024}})\"", + ]) + title = f"외부 노출 {type_korean} '{component.name}' 호출" + if len(actions) > 1: + title += f" — {len(actions)} actions" + artifacts.append(PoCArtifact( + rule_id=finding.rule_id, + title=title, + severity=finding.severity, + kind="adb", + package_name=pkg, + component=component.name, + description=( + f"외부에 노출된 {type_korean}가 권한 없이 외부 인텐트를 수용합니다. " + f"이 컴포넌트는 {len(actions)}개의 intent-filter action을 가지며, " + "본 레시피는 각 action을 차례로 시도합니다 — 실제 공격에서는 " + "onCreate/onReceive 안의 신뢰 결정 지점으로 전달되는 extras를 " + "조작해 함께 보냅니다." + ), + commands=commands, + expected_evidence=( + "컴포넌트가 호출자 shell uid(2000)로 시작/수신됩니다 (앱 uid가 " + "아님). logcat에 onCreate/onStartCommand/onReceive 진입 로그가 " + "남습니다." + ), + references=list(finding.references), + )) artifacts.extend(_deeplink_pocs(component, pkg, finding)) artifacts.append(_frida_intent_observer(component, pkg, finding)) return artifacts @@ -1009,6 +1028,78 @@ def generate_code_pocs( } +import re as _re_for_dedup + + +# Tokens that almost always vary between PoCs that share the same +# template — class names, component FQNs, action URI hosts. Substituting +# them with a placeholder lets us detect "same shape, different target." +_DEDUP_REPLACE_RE = _re_for_dedup.compile( + r"(com\.[\w$.]+|" # any com.* class FQN + r"kakao[\w.]+://[\w./?#=&-]*|" # deeplink URIs (kakaotalk://, kakaopay://, ...) + r"https?://[\w./?#=&-]+|" # http/https URIs + r"[\w-]+\.[\w]+\.[\w./-]+" # generic dotted host or class path + r")" +) + + +def _template_signature(p: PoCArtifact) -> tuple: + """Reduce a PoCArtifact to a hashable signature that ignores the + components/URIs it targets. + + Two PoCs sharing this signature are templates of the same shape — + same rule, same channel, same command pattern, same step count. + Used by ``dedup_pocs_by_template`` to merge them while preserving + each target in ``applies_to``. + """ + normalized_cmds = tuple( + _DEDUP_REPLACE_RE.sub("", c) for c in p.commands + ) + normalized_title = _DEDUP_REPLACE_RE.sub("", p.title) + return (p.rule_id, p.kind, p.severity, normalized_title, normalized_cmds) + + +def dedup_pocs_by_template( + artifacts: Iterable[PoCArtifact], +) -> list[PoCArtifact]: + """Phase 11-3: collapse PoCs that share a template into one + representative + ``applies_to`` listing each merged target. + + KakaoTalk audit emitted 190 PoCArtifacts; many were the same shell + template parametrised by component or class FQN (3.7× polynomial + blow-up). Operators got identical .sh scripts with only the + component string changing — exhausting to read. + + The first artifact for a signature stays as-is. Subsequent matches + contribute their ``component`` (or class_fqn-shaped first token of + the title) into the representative's ``applies_to`` list. The + representative's commands and metadata are untouched so any one + .sh stays runnable. + + Stable: output order matches first-seen order of each signature. + Pure function; the artifacts passed in are not mutated when they + survive as representatives, but the merged ``applies_to`` list is + extended on the representative object — callers needing immutable + inputs should pass copies. + """ + out: list[PoCArtifact] = [] + seen: dict[tuple, int] = {} + for p in artifacts: + sig = _template_signature(p) + idx = seen.get(sig) + if idx is None: + seen[sig] = len(out) + out.append(p) + continue + rep = out[idx] + # Record this artifact's component (or fall back to its title + # fragment) on the representative. + target = p.component or p.title + if target and target != rep.component and target not in rep.applies_to: + rep.applies_to.append(target) + return out + + def generate_pocs( meta: AndroidAppMeta, report: AndroidAuditReport ) -> list[PoCArtifact]: @@ -1017,6 +1108,11 @@ def generate_pocs( Findings whose rule_id has no builder (informational rules like MANIFEST-007..009) are skipped silently. Output order mirrors ``report.findings`` so the operator can read PoCs alongside the audit. + + Phase 11-3: builders may emit one PoC per finding-target combination + even when the template is identical. ``dedup_pocs_by_template`` + folds those duplicates so the operator sees one .sh per unique + template with the affected components rolled into ``applies_to``. """ artifacts: list[PoCArtifact] = [] for f in report.findings: @@ -1024,7 +1120,7 @@ def generate_pocs( if builder is None: continue artifacts.extend(builder(meta, f)) - return artifacts + return dedup_pocs_by_template(artifacts) def format_pocs_text(artifacts: list[PoCArtifact]) -> str: diff --git a/src/venomhook/smali_audit.py b/src/venomhook/smali_audit.py index c6b8c1c..cc25ede 100644 --- a/src/venomhook/smali_audit.py +++ b/src/venomhook/smali_audit.py @@ -53,6 +53,7 @@ from venomhook.code_audit import ( DEFAULT_THIRD_PARTY_PREFIXES, _strip_line_comment, # quote-aware to match code_audit conventions + dedup_findings_by_class, ) from venomhook.models import ( AndroidAppMeta, @@ -390,6 +391,10 @@ def audit_smali( counts_per_rule[rule.rule_id] += 1 package_name = app_meta.package_name if app_meta else "" + # Phase 11-1: same dedup the .java tier uses — KakaoTalk's + # smali tier saw 306 findings before dedup; same (rule, class) + # collapse turns it into representative + occurrences. + findings = dedup_findings_by_class(findings) return CodeAuditReport( package_name=package_name, findings=findings, @@ -403,15 +408,24 @@ def merge_code_reports( ) -> Optional[CodeAuditReport]: """Combine .java and smali findings, preferring .java on overlap. - Two findings are considered the same when their (rule_id, class_fqn) - match — the .java tier wins because its evidence (line text) is - more readable. The smali tier covers the gap when jadx had no - output for a given class. + Two findings are considered the same when their + (rule_id, class_fqn, severity) match — the .java tier wins because + its evidence (line text) is more readable. The smali tier covers + the gap when jadx had no output for a given class. + + Phase 11-5: when both tiers fired on the same (rule, class, severity), + the smali finding's primary line + its occurrences are folded into + the java representative's ``occurrences`` list as additional + smali-tier evidence. This avoids dropping useful smali matches + silently — even when java tier ran, smali may have caught extra + lines that jadx couldn't decompile. Either argument can be None; result is the other (or None when both are None). When both are present, the smali tier's files_scanned is added to the result for completeness, and partial flag is OR'd. """ + from venomhook.models import CodeOccurrence + if java_report is None and smali_report is None: return None if java_report is None: @@ -419,16 +433,38 @@ def merge_code_reports( if smali_report is None: return java_report - seen: set[tuple[str, str]] = { - (f.rule_id, f.class_fqn) for f in java_report.findings - } + # Index java findings by (rule, class_or_file, severity) so a smali + # match on the same key can fold instead of duplicate. + java_index: dict[tuple[str, str, str], CodeFinding] = {} + for jf in java_report.findings: + key = (jf.rule_id, jf.class_fqn or jf.file, jf.severity) + # Last write wins on collision (shouldn't happen post P11-1 dedup + # but defensive). Keep the first instead so order matches input. + java_index.setdefault(key, jf) + merged: list[CodeFinding] = list(java_report.findings) - for f in smali_report.findings: - key = (f.rule_id, f.class_fqn) - if key in seen: + for sf in smali_report.findings: + key = (sf.rule_id, sf.class_fqn or sf.file, sf.severity) + rep = java_index.get(key) + if rep is None: + # No overlap — keep the smali finding as a separate entry. + merged.append(sf) continue - merged.append(f) - seen.add(key) + # Same (rule, class, severity) in both tiers — fold smali into + # the java representative as additional evidence. + rep.occurrences.append(CodeOccurrence( + line_no=sf.line_no, + line_text=sf.line_text, + file=sf.file if sf.file != rep.file else "", + evidence_tier="smali", + )) + for occ in sf.occurrences: + rep.occurrences.append(CodeOccurrence( + line_no=occ.line_no, + line_text=occ.line_text, + file=occ.file if occ.file != rep.file else "", + evidence_tier="smali", + )) return CodeAuditReport( package_name=java_report.package_name or smali_report.package_name,