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
1 change: 1 addition & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`/`동의`).

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`/`동의`).

Expand Down Expand Up @@ -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)*
Expand Down
1 change: 1 addition & 0 deletions cmd/klaws/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
82 changes: 82 additions & 0 deletions internal/detector/transfer.go
Original file line number Diff line number Diff line change
@@ -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
}
89 changes: 89 additions & 0 deletions internal/detector/transfer_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
1 change: 1 addition & 0 deletions internal/mcp/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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("")
Expand Down
2 changes: 2 additions & 0 deletions internal/scanner/scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ func TestScanDirectory_FindsRisks(t *testing.T) {
detector.NewFinancialDataDetector(),
detector.NewRetentionDetector(),
detector.NewPersonalDataRetentionDetector(),
detector.NewThirdPartyTransferDetector(),
)
svc := scanner.NewService(reg)

Expand Down Expand Up @@ -50,6 +51,7 @@ func TestScanDirectory_NoRisksInCleanFile(t *testing.T) {
detector.NewFinancialDataDetector(),
detector.NewRetentionDetector(),
detector.NewPersonalDataRetentionDetector(),
detector.NewThirdPartyTransferDetector(),
)
svc := scanner.NewService(reg)

Expand Down
15 changes: 15 additions & 0 deletions testdata/TransferService.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading