diff --git a/README.ko.md b/README.ko.md index c817ec0..dc46332 100644 --- a/README.ko.md +++ b/README.ko.md @@ -135,6 +135,7 @@ klaws detectors | `CIA-ENC-001` | 신용정보 미보호 위험 | 신용/금융 식별자 필드(카드번호, 계좌번호, 신용등급 등)에 암호화 또는 마스킹 누락 여부 | HIGH | 신용정보법 제19조 | | `ECA-RET-001` | 거래기록 보존 위험 | 거래기록 필드(주문/결제 ID 등)에 보존 또는 보관 처리 누락 여부 | MEDIUM | 전자상거래법 제6조 | | `PIPA-RET-001` | 개인정보 파기 위험 | 개인정보 필드(이메일, 전화번호, 주민번호 등)에 파기 또는 보관기간 처리 누락 여부 | MEDIUM | 개인정보보호법 제21조 | +| `PIPA-XBR-001` | 제3자 제공 위험 | 개인정보를 외부 URL·제휴사 등 제3자에게 전송(외부 호출) 시 동의 확인 누락 여부 | HIGH | 개인정보보호법 제17조 | 탐지기는 정규식 기반 패턴 매칭을 사용합니다. 영문과 한글 필드명을 모두 지원합니다 (예: `email`/`이메일`, `residentNumber`/`주민번호`, `consent`/`동의`). diff --git a/README.md b/README.md index 512d99e..2b0c479 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ klaws detectors | `CIA-ENC-001` | Unprotected Credit Information | Credit/financial identifier fields (card number, account number, credit score) without encryption or masking | HIGH | Credit Information Act Art. 19 | | `ECA-RET-001` | Transaction Record Retention | Transaction record fields (order/payment IDs) stored without apparent retention or preservation handling | MEDIUM | E-Commerce Act Art. 6 | | `PIPA-RET-001` | Personal Data Retention | Personal data fields (email, phone, resident number) stored without apparent destruction or retention-limit handling | MEDIUM | PIPA Art. 21 | +| `PIPA-XBR-001` | Third-Party Data Transfer | Personal data sent to a third-party or external endpoint (outbound call to an external URL/partner) without an apparent consent check | HIGH | PIPA Art. 17 | Detectors use regex-based pattern matching. They support both English and Korean field names (e.g., `email`/`이메일`, `residentNumber`/`주민번호`, `consent`/`동의`). @@ -363,7 +364,7 @@ klaws scan ./src ## Roadmap -- **More detectors:** marketing-message consent (NIA-MKT-001) *(done)*, unprotected credit information (CIA-ENC-001) *(done)*, transaction-record retention (ECA-RET-001) *(done)*, personal-data retention (PIPA-RET-001) *(done)*; next: cross-border transfer (PIPA-XBR-001) +- **More detectors:** marketing-message consent (NIA-MKT-001) *(done)*, unprotected credit information (CIA-ENC-001) *(done)*, transaction-record retention (ECA-RET-001) *(done)*, personal-data retention (PIPA-RET-001) *(done)*, third-party/cross-border transfer (PIPA-XBR-001) *(done)* - **Multi-language:** Python, JavaScript/TypeScript detection patterns - **More Korean laws:** E-Commerce Act (전자상거래법) consumer protection rules *(done)*, Network Act (정보통신망법) *(done)*, Credit Information Act (신용정보법) *(done)* - **CI/CD:** GitHub Action, SARIF output, severity thresholds *(done)* diff --git a/cmd/klaws/main.go b/cmd/klaws/main.go index 54831df..d338c09 100644 --- a/cmd/klaws/main.go +++ b/cmd/klaws/main.go @@ -89,6 +89,7 @@ func buildDeps() (*scanner.ScannerService, *detector.Registry, *law.Registry, er detector.NewFinancialDataDetector(), detector.NewRetentionDetector(), detector.NewPersonalDataRetentionDetector(), + detector.NewThirdPartyTransferDetector(), ) svc := scanner.NewService(detReg) diff --git a/internal/detector/transfer.go b/internal/detector/transfer.go new file mode 100644 index 0000000..833ea5a --- /dev/null +++ b/internal/detector/transfer.go @@ -0,0 +1,82 @@ +package detector + +import ( + "regexp" + "strings" + + "github.com/rostradamus/klaws/internal/report" +) + +// outboundTransferRe matches an outbound request/RPC call (a method invocation +// on a client, e.g. restTemplate.postForObject(...), webClient.exchange(...), +// httpClient.send(...)). The leading "." requires a receiver, so type names in +// declarations do not trigger it. +var outboundTransferRe = regexp.MustCompile(`(?i)\.\s*(post\w*|put\w*|exchange|execute|send)\s*\(`) + +// externalTargetRe signals that the destination is a third party or external +// endpoint (as opposed to an internal repository/service call). +var externalTargetRe = regexp.MustCompile( + `(?i)(https?://|third[_-]?party|external|partner|overseas|cross[_-]?border|제3자|외부|해외|국외)`, +) + +// xbrPersonalDataRe matches high-signal personal-data terms whose transfer to a +// third party is governed by PIPA Article 17. +var xbrPersonalDataRe = regexp.MustCompile( + `(?i)(\b(email|phone_?number|mobile|resident_?number|ssn|birth_?date|passport)\b|이메일|전화번호|주민번호|여권)`, +) + +// xbrConsentRe matches evidence that consent to provide the data was checked. +// "agree" is anchored to a word start so that an explicit non-consent token such +// as "disagree" does not read as consent and suppress a finding. +var xbrConsentRe = regexp.MustCompile(`(?i)(consent|\bagree|동의|제공 ?동의)`) + +type ThirdPartyTransferDetector struct{} + +func NewThirdPartyTransferDetector() *ThirdPartyTransferDetector { + return &ThirdPartyTransferDetector{} +} + +func (d *ThirdPartyTransferDetector) ID() string { return "PIPA-XBR-001" } +func (d *ThirdPartyTransferDetector) Name() string { return "Third-Party Data Transfer Risk" } +func (d *ThirdPartyTransferDetector) Description() string { + return "Detects personal data sent to a third-party or external endpoint without an apparent consent check" +} +func (d *ThirdPartyTransferDetector) RelatedLawIDs() []string { return []string{"PIPA-17"} } + +func (d *ThirdPartyTransferDetector) Scan(sourceCode string, filePath string) []report.Finding { + var findings []report.Finding + lines := strings.Split(sourceCode, "\n") + + for i, line := range lines { + if lineCommentRe.MatchString(line) { + continue + } + if !outboundTransferRe.MatchString(line) { + continue + } + + // Require all three signals near the call: an external destination, + // personal data, and no consent check. windowAround skips comment lines. + window := windowAround(lines, i, 10) + if !externalTargetRe.MatchString(window) { + continue + } + if !xbrPersonalDataRe.MatchString(window) { + continue + } + if xbrConsentRe.MatchString(window) { + continue + } + + findings = append(findings, report.Finding{ + DetectorID: d.ID(), + RiskLevel: "HIGH", + FilePath: filePath, + LineNumber: i + 1, + Snippet: strings.TrimSpace(line), + Message: "Possible transfer of personal data to a third party or external endpoint without an apparent consent check — may require review under PIPA Article 17", + RelatedLaws: d.RelatedLawIDs(), + }) + } + return findings +} diff --git a/internal/detector/transfer_test.go b/internal/detector/transfer_test.go new file mode 100644 index 0000000..1c48ba8 --- /dev/null +++ b/internal/detector/transfer_test.go @@ -0,0 +1,89 @@ +package detector_test + +import ( + "os" + "testing" + + "github.com/rostradamus/klaws/internal/detector" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestThirdPartyTransferDetector_Detects_ExternalTransfer(t *testing.T) { + src, err := os.ReadFile("../../testdata/TransferService.java") + require.NoError(t, err) + + d := detector.NewThirdPartyTransferDetector() + findings := d.Scan(string(src), "TransferService.java") + + assert.Equal(t, 1, len(findings), "should flag one external transfer of personal data") + + f := findings[0] + assert.Equal(t, "PIPA-XBR-001", f.DetectorID) + assert.Equal(t, "HIGH", f.RiskLevel) + assert.Contains(t, f.Message, "may require review") + assert.Contains(t, f.RelatedLaws, "PIPA-17") +} + +func TestThirdPartyTransferDetector_SkipsWhenConsentPresent(t *testing.T) { + src := ` + public void share(String email) { + if (!user.hasProvisionConsent()) { return; } + restTemplate.postForObject("https://partner.example.com/api", email, Void.class); + }` + d := detector.NewThirdPartyTransferDetector() + findings := d.Scan(src, "Clean.java") + assert.Empty(t, findings) +} + +func TestThirdPartyTransferDetector_DisagreeIsNotConsent(t *testing.T) { + // "disagree" is an explicit non-consent signal — it must not be read as + // consent and suppress the finding. + src := ` + public void share(String email) { + if (user.disagree()) { transfer(); } + restTemplate.postForObject("https://partner.example.com/api", email, Void.class); + }` + d := detector.NewThirdPartyTransferDetector() + findings := d.Scan(src, "Disagree.java") + assert.Equal(t, 1, len(findings), "disagree must not count as consent") +} + +func TestThirdPartyTransferDetector_SkipsInternalCall(t *testing.T) { + // No external/third-party signal — an internal save is not a transfer. + src := ` + public void save(String email) { + userRepository.save(new User(email)); + }` + d := detector.NewThirdPartyTransferDetector() + findings := d.Scan(src, "Clean.java") + assert.Empty(t, findings) +} + +func TestThirdPartyTransferDetector_SkipsWhenNoPersonalData(t *testing.T) { + // External call, but no personal data in the payload. + src := ` + public void ping() { + restTemplate.postForObject("https://partner.example.com/health", "ping", Void.class); + }` + d := detector.NewThirdPartyTransferDetector() + findings := d.Scan(src, "Clean.java") + assert.Empty(t, findings) +} + +func TestThirdPartyTransferDetector_Detects_KoreanSignals(t *testing.T) { + src := ` + public void 제공(String 이메일) { + client.send(외부API, 이메일); + }` + d := detector.NewThirdPartyTransferDetector() + findings := d.Scan(src, "Korean.java") + assert.Equal(t, 1, len(findings)) + assert.Equal(t, "PIPA-XBR-001", findings[0].DetectorID) +} + +func TestThirdPartyTransferDetector_Metadata(t *testing.T) { + d := detector.NewThirdPartyTransferDetector() + assert.Equal(t, "PIPA-XBR-001", d.ID()) + assert.Equal(t, []string{"PIPA-17"}, d.RelatedLawIDs()) +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go index 34bca2c..948b57f 100644 --- a/internal/mcp/server_test.go +++ b/internal/mcp/server_test.go @@ -38,6 +38,7 @@ func setupServer(t *testing.T) *server.MCPServer { detector.NewFinancialDataDetector(), detector.NewRetentionDetector(), detector.NewPersonalDataRetentionDetector(), + detector.NewThirdPartyTransferDetector(), ) svc := scanner.NewService(reg) lawReg, err := law.NewRegistry("") diff --git a/internal/scanner/scanner_test.go b/internal/scanner/scanner_test.go index 9ac400d..62d2fae 100644 --- a/internal/scanner/scanner_test.go +++ b/internal/scanner/scanner_test.go @@ -18,6 +18,7 @@ func TestScanDirectory_FindsRisks(t *testing.T) { detector.NewFinancialDataDetector(), detector.NewRetentionDetector(), detector.NewPersonalDataRetentionDetector(), + detector.NewThirdPartyTransferDetector(), ) svc := scanner.NewService(reg) @@ -50,6 +51,7 @@ func TestScanDirectory_NoRisksInCleanFile(t *testing.T) { detector.NewFinancialDataDetector(), detector.NewRetentionDetector(), detector.NewPersonalDataRetentionDetector(), + detector.NewThirdPartyTransferDetector(), ) svc := scanner.NewService(reg) diff --git a/testdata/TransferService.java b/testdata/TransferService.java new file mode 100644 index 0000000..beafd70 --- /dev/null +++ b/testdata/TransferService.java @@ -0,0 +1,15 @@ +package com.example.transfer; + +public class TransferService { + + private final RestTemplate restTemplate; + + public TransferService(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + public void share(String email, String phoneNumber) { + String url = "https://partner.example.com/api/import"; + restTemplate.postForObject(url, new Payload(email, phoneNumber), Void.class); + } +}