diff --git a/internal/qualitygate/config/README.md b/internal/qualitygate/config/README.md index 634e64bff..0f6865bb7 100644 --- a/internal/qualitygate/config/README.md +++ b/internal/qualitygate/config/README.md @@ -45,6 +45,18 @@ Adding a new row requires approval from the matching CODEOWNERS or quality gate `legacy-commands.txt` only covers hand-authored legacy commands. Generated OpenAPI service commands are intentionally excluded from `command-manifest.json`; they are included in `command-index.json` only so command references can be checked against the real CLI surface. +## Public Domain Allowlists + +`internal/qualitygate/config/allowlists/public-domains.txt` contains supported public hostnames approved for Go source. `fixture-domains.txt` contains test-only hostnames used by `*_test.go`, the repository-root `tests/` directory, or any `testdata/` directory; fixture entries do not apply to production Go files or `skills/`. + +Keep one lowercase exact hostname per line, sorted alphabetically. Wildcards, suffix rules, duplicates, schemes, ports, and paths are rejected; approving `larkoffice.com` does not approve its subdomains. + +RFC 2606 reserves the `.test`, `.example`, `.invalid`, and `.localhost` namespaces plus the exact names `example.com`, `example.net`, and `example.org`. These names are accepted without an allowlist entry and must not be listed. + +Every public entry needs a current non-fixture Go use, evidence that it is a supported public endpoint, and CODEOWNER approval. Other test-only hostnames belong in the fixture list. Tenant-specific, private-control-plane, and internal API hostnames are not eligible. + +`lint/domaincontract` validates both lists and scans complete Go files. In CI, unapproved-host findings are limited to values whose expressions intersect added lines; list validation and unused-entry checks remain repository-wide. See `lint/README.md` for scanner semantics. + ## Semantic Blocker Policy The semantic reviewer can propose findings, but the local gatekeeper recomputes whether each finding is reproducible from `facts.json`. A finding blocks only when all of these are true: diff --git a/internal/qualitygate/config/allowlists/fixture-domains.txt b/internal/qualitygate/config/allowlists/fixture-domains.txt new file mode 100644 index 000000000..7f3b14395 --- /dev/null +++ b/internal/qualitygate/config/allowlists/fixture-domains.txt @@ -0,0 +1,24 @@ +# Exact test-only hostnames. Keep sorted. +abc.feishu.cn +attacker.example.com +bytedance.feishu.cn +cdn.feishu.cn +evil.example.com +example.feishu.cn +example.larkoffice.com +example.larksuite.com +feishu.cn +feishu.doubao.com +gateway.docker.internal +host.containers.internal +host.docker.internal +host.lima.internal +lf3-static.bytednsdoc.com +meetings.feishu.cn +meetings.larksuite.com +p3-lark-file.byteimg.com +passport.feishu.cn +sample.feishu.cn +x.feishu.cn +xxx.feishu.cn +xxx.larksuite.com diff --git a/internal/qualitygate/config/allowlists/public-domains.txt b/internal/qualitygate/config/allowlists/public-domains.txt new file mode 100644 index 000000000..3bf6e32c4 --- /dev/null +++ b/internal/qualitygate/config/allowlists/public-domains.txt @@ -0,0 +1,18 @@ +# Exact public hostnames. Keep sorted. +accounts.feishu.cn +accounts.larksuite.com +applink.feishu.cn +applink.larksuite.com +ark.ap-southeast.bytepluses.com +github.com +larkoffice.com +lf-larkemail.bytetos.com +mcp.feishu.cn +mcp.larksuite.com +open.feishu.cn +open.larksuite.com +registry.npmjs.org +registry.npmmirror.com +sf16-sg.tiktokcdn.com +www.feishu.cn +www.larksuite.com diff --git a/lint/README.md b/lint/README.md index 79d246961..805fbba1f 100644 --- a/lint/README.md +++ b/lint/README.md @@ -19,7 +19,7 @@ lint/ ├── lintapi/ # shared types every domain returns │ └── violation.go # Violation, Action, ActionReject / ActionLabel / ActionWarning └── errscontract/ # first domain: typed-error contract guards - ├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry + ├── scan.go # ScanRepoWithOptions(root, opts) ← public entry ├── runner.go ├── typecheck.go ├── violation.go # local type aliases to lintapi @@ -30,16 +30,19 @@ lint/ ├── rule_subtype_classifier.go ├── rule_typed_error_completeness.go └── *_test.go -└── domaincontract/ # endpoint domain contract: no hardcoded resolver hosts - ├── scan.go # ScanRepo(root) ([]lintapi.Violation, error) ← public entry - └── scan_test.go +└── domaincontract/ # resolver ownership + approved public hostname policy + ├── scan.go # ScanRepoWithOptions(root, opts) ← public entry + ├── unapproved.go # Go AST/type-aware hostname extraction + ├── policy.go # exact public/fixture allowlist validation + ├── diff.go # added-line attribution + └── *_test.go ``` ## Endpoint domain contract (`domaincontract`) -`domaincontract` is a syntax-level regression guard for the resolver-owned -Open, Accounts, MCP, and AppLink hosts used by the Go CLI. In production `.go` -files it rejects: +`domaincontract` contains two complementary Go source guards. + +The resolver-ownership guard rejects: - string literals containing a resolver-owned host FQDN (`{open,accounts,mcp,applink}.{feishu.cn,larksuite.com}`), and @@ -59,17 +62,54 @@ parse-level guard). The forbidden-host list is bound to the resolver source by `TestForbiddenHostsMatchResolver`, so adding a resolver domain without updating the guard fails the lint module's tests. -This is not a general outbound-URL or data-flow analyzer. It does not inspect -non-Go assets, hosts assembled from string fragments, SDK constructor option -flow, or previously unknown Feishu/Lark hosts. The literal rule and code review -remain the backstop for those cases. +The approved-domain guard parses every Git-tracked Go file in full. In CI, +unapproved-host findings are limited to values whose expressions intersect an +added line; policy validation and unused-entry checks remain repository-wide. +It rejects an exact hostname unless it is present in one of: -To add or change an outbound endpoint, edit the resolver — never hardcode a host. +- `internal/qualitygate/config/allowlists/public-domains.txt`, for production + and test code; or +- `internal/qualitygate/config/allowlists/fixture-domains.txt`, only for + `*_test.go`, the repository-root `tests/`, and any `testdata/` (never + `skills/`). + +RFC 2606 example/test names are accepted independently of those lists. This +includes the reserved `.test`, `.example`, `.invalid`, and `.localhost` +namespaces and the exact names `example.com`, `example.net`, and `example.org`; +they are safe placeholders rather than supported public endpoints. + +High-confidence evidence is deliberately limited to static string expressions +assigned to `host`, `hostname`, or `domain` semantic names (including common +case/plural forms and collections), plus static strings whose entire value is +an absolute `http`, `https`, `ws`, or `wss` URL. It supports Go literals, +escapes, compile-time concatenation, constant references, grouped declarations, +multi-value assignments, and multiline expressions. Bare domain-shaped strings +without hostname semantics are not blocked. + +Sequence values are scanned individually. For a hostname-semantic map, a key or +value is evidence only when it is the sole hostname-shaped side of that entry; +ambiguous string-to-string entries are not guessed. Struct fields use Go type +information so known non-network `Host` / `Domain` fields do not become hostname +evidence merely because an enum or command category contains a dot. + +Allowlist matching is lowercase and exact: there are no wildcard, suffix, DNS, +or public-suffix exceptions. Entries must be sorted and unique, use ASCII +hostnames, and have a current in-scope use. See +`internal/qualitygate/config/README.md` for admission and approval rules. + +This is not a general outbound-URL or cross-language data-flow analyzer. It does +not inspect non-Go assets or dynamically constructed values. + +To add or change a resolver-owned Feishu/Lark endpoint, edit the resolver rather +than hardcoding the host elsewhere. ## Running ```bash -# from the repo root (one level above lint/) +# PR-scoped scan from the repo root (one level above lint/) +go run -C lint . --changed-from .. + +# Full inventory (also reports historical unapproved hostnames) go run -C lint . .. ``` @@ -100,10 +140,14 @@ Exit codes follow `lint/main.go`: import "github.com/larksuite/cli/lint/lintapi" - // ScanRepo walks root and returns every violation produced by this - // domain's checks. Domains MUST return []lintapi.Violation so the - // top-level dispatcher can aggregate uniformly. - func ScanRepo(root string) ([]lintapi.Violation, error) { ... } + type ScanOptions struct { + ChangedFrom string + } + + // ScanRepoWithOptions walks root and returns every violation produced + // by this domain's checks. Domains MUST return []lintapi.Violation so + // the top-level dispatcher can aggregate uniformly. + func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { ... } ``` 3. Per-rule files are named `rule_.go` with sibling @@ -114,8 +158,12 @@ Exit codes follow `lint/main.go`: ```go var scanners = []scanner{ - {name: "errscontract", fn: errscontract.ScanRepo}, - {name: "", fn: .ScanRepo}, // ← add here + {name: "errscontract", fn: errscontract.ScanRepoWithOptions}, + {name: "", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) { + return .ScanRepoWithOptions(root, .ScanOptions{ + ChangedFrom: opts.ChangedFrom, + }) + }}, } ``` diff --git a/lint/domaincontract/diff.go b/lint/domaincontract/diff.go new file mode 100644 index 000000000..69b69b9a4 --- /dev/null +++ b/lint/domaincontract/diff.go @@ -0,0 +1,171 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "bytes" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +type addedLineRange struct { + Start int + End int +} + +type changedGoPath struct { + Old string + New string +} + +var unifiedHunkRE = regexp.MustCompile(`^@@ -[0-9]+(?:,[0-9]+)? \+([0-9]+)(?:,([0-9]+))? @@`) + +func changedGoLineRanges(root, from string) (map[string][]addedLineRange, error) { + if from == "" { + return nil, nil + } + names, err := gitCommandOutput( + root, + "diff", + "--name-status", + "-z", + "--find-renames", + "--diff-filter=ACMR", + from+"...HEAD", + "--", + ) + if err != nil { + return nil, fmt.Errorf("list changed Go files: %w", err) + } + paths, err := parseChangedGoPaths(names) + if err != nil { + return nil, fmt.Errorf("parse changed Go files: %w", err) + } + + out := map[string][]addedLineRange{} + for _, path := range paths { + args := []string{ + "diff", + "--unified=0", + "--no-color", + "--no-ext-diff", + "--find-renames", + "--diff-filter=ACMR", + from + "...HEAD", + "--", + } + if path.Old != path.New { + args = append(args, path.Old) + } + args = append(args, path.New) + patch, err := gitCommandOutput(root, args...) + if err != nil { + return nil, fmt.Errorf("read diff for %s: %w", path.New, err) + } + ranges, err := parseAddedLineRanges(patch) + if err != nil { + return nil, fmt.Errorf("parse diff for %s: %w", path.New, err) + } + out[path.New] = ranges + } + return out, nil +} + +func parseChangedGoPaths(raw []byte) ([]changedGoPath, error) { + fields := bytes.Split(raw, []byte{0}) + var out []changedGoPath + for i := 0; i < len(fields); { + status := string(fields[i]) + i++ + if status == "" { + break + } + if i >= len(fields) || len(fields[i]) == 0 { + return nil, fmt.Errorf("truncated name-status record") + } + oldPath := filepath.ToSlash(string(fields[i])) + i++ + newPath := oldPath + if status[0] == 'R' || status[0] == 'C' { + if i >= len(fields) || len(fields[i]) == 0 { + return nil, fmt.Errorf("truncated rename/copy record for %q", oldPath) + } + newPath = filepath.ToSlash(string(fields[i])) + i++ + if status[0] == 'C' { + // A copy introduces every destination line. Diff only the new + // path so Git presents it as an added file rather than a + // metadata-only copy with no added-line ranges. + oldPath = newPath + } + } + if !strings.HasSuffix(newPath, ".go") { + continue + } + out = append(out, changedGoPath{Old: oldPath, New: newPath}) + } + return out, nil +} + +func parseAddedLineRanges(patch []byte) ([]addedLineRange, error) { + var out []addedLineRange + for _, raw := range bytes.Split(patch, []byte{'\n'}) { + line := string(raw) + if !strings.HasPrefix(line, "@@") { + continue + } + match := unifiedHunkRE.FindStringSubmatch(line) + if match == nil { + return nil, fmt.Errorf("unsupported unified hunk header %q", line) + } + start, err := strconv.Atoi(match[1]) + if err != nil { + return nil, fmt.Errorf("parse added start line in %q: %w", line, err) + } + count := 1 + if match[2] != "" { + count, err = strconv.Atoi(match[2]) + if err != nil { + return nil, fmt.Errorf("parse added line count in %q: %w", line, err) + } + } + if count == 0 { + continue + } + out = append(out, addedLineRange{Start: start, End: start + count - 1}) + } + return out, nil +} + +func firstAddedLineInSpan(ranges []addedLineRange, start, end int) (int, bool) { + for _, r := range ranges { + if start <= r.End && end >= r.Start { + if start > r.Start { + return start, true + } + return r.Start, true + } + } + return 0, false +} + +func gitCommandOutput(root string, args ...string) ([]byte, error) { + cmd := exec.Command("git", args...) + cmd.Dir = root + out, err := cmd.Output() + if err == nil { + return out, nil + } + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + if stderr != "" { + return nil, fmt.Errorf("%w: %s", err, stderr) + } + } + return nil, err +} diff --git a/lint/domaincontract/diff_test.go b/lint/domaincontract/diff_test.go new file mode 100644 index 000000000..8ffbd70bd --- /dev/null +++ b/lint/domaincontract/diff_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import "testing" + +func TestParseChangedGoPaths(t *testing.T) { + raw := []byte("M\x00changed.go\x00R100\x00old.go\x00renamed.go\x00C100\x00source.go\x00copied.go\x00A\x00README.md\x00") + got, err := parseChangedGoPaths(raw) + if err != nil { + t.Fatal(err) + } + want := []changedGoPath{ + {Old: "changed.go", New: "changed.go"}, + {Old: "old.go", New: "renamed.go"}, + {Old: "copied.go", New: "copied.go"}, + } + if len(got) != len(want) { + t.Fatalf("paths = %#v, want %#v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("paths = %#v, want %#v", got, want) + } + } +} + +func TestParseChangedGoPathsRejectsTruncatedRename(t *testing.T) { + if _, err := parseChangedGoPaths([]byte("R100\x00old.go\x00")); err == nil { + t.Fatal("expected truncated rename error") + } +} + +func TestParseAddedLineRanges(t *testing.T) { + patch := []byte(`diff --git a/x.go b/x.go +index 1111111..2222222 100644 +--- a/x.go ++++ b/x.go +@@ -2,0 +3,2 @@ ++first ++second +@@ -10 +12 @@ +-old ++new +@@ -20 +21,0 @@ +-deleted +`) + got, err := parseAddedLineRanges(patch) + if err != nil { + t.Fatal(err) + } + want := []addedLineRange{{Start: 3, End: 4}, {Start: 12, End: 12}} + if len(got) != len(want) { + t.Fatalf("ranges = %#v, want %#v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("ranges = %#v, want %#v", got, want) + } + } +} + +func TestParseAddedLineRangesRejectsUnknownHunk(t *testing.T) { + if _, err := parseAddedLineRanges([]byte("@@@ unsupported @@@\n")); err == nil { + t.Fatal("expected unsupported hunk error") + } +} + +func TestFirstAddedLineInSpan(t *testing.T) { + ranges := []addedLineRange{{Start: 5, End: 7}, {Start: 10, End: 10}} + tests := []struct { + start, end int + line int + ok bool + }{ + {start: 1, end: 4, ok: false}, + {start: 4, end: 6, line: 5, ok: true}, + {start: 6, end: 9, line: 6, ok: true}, + {start: 8, end: 12, line: 10, ok: true}, + } + for _, tc := range tests { + line, ok := firstAddedLineInSpan(ranges, tc.start, tc.end) + if line != tc.line || ok != tc.ok { + t.Errorf( + "firstAddedLineInSpan(%d, %d) = (%d, %v), want (%d, %v)", + tc.start, + tc.end, + line, + ok, + tc.line, + tc.ok, + ) + } + } +} diff --git a/lint/domaincontract/policy.go b/lint/domaincontract/policy.go new file mode 100644 index 000000000..64269a34c --- /dev/null +++ b/lint/domaincontract/policy.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + publicDomainsPath = "internal/qualitygate/config/allowlists/public-domains.txt" + fixtureDomainsPath = "internal/qualitygate/config/allowlists/fixture-domains.txt" +) + +type domainPolicyEntry struct { + Host string + File string + Line int +} + +type domainPolicy struct { + Public map[string]domainPolicyEntry + Fixtures map[string]domainPolicyEntry +} + +// isReservedExampleHostname recognizes only names reserved by RFC 2606 for +// examples, testing, invalid-name examples, and localhost use. These names are +// safe source placeholders and are policy exceptions, not supported public +// endpoints. +func isReservedExampleHostname(host string) bool { + host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + switch host { + case "example.com", "example.net", "example.org": + return true + } + labels := strings.Split(host, ".") + switch labels[len(labels)-1] { + case "test", "example", "invalid", "localhost": + return true + default: + return false + } +} + +func loadDomainPolicy(root string) (domainPolicy, error) { + public, err := loadDomainList(root, publicDomainsPath) + if err != nil { + return domainPolicy{}, err + } + fixtures, err := loadDomainList(root, fixtureDomainsPath) + if err != nil { + return domainPolicy{}, err + } + for host, entry := range fixtures { + if publicEntry, ok := public[host]; ok { + return domainPolicy{}, fmt.Errorf( + "%s:%d: hostname %q is already listed at %s:%d", + entry.File, entry.Line, host, publicEntry.File, publicEntry.Line, + ) + } + } + return domainPolicy{Public: public, Fixtures: fixtures}, nil +} + +func loadDomainList(root, rel string) (map[string]domainPolicyEntry, error) { + path := filepath.Join(root, filepath.FromSlash(rel)) + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open domain allowlist %s: %w", rel, err) + } + defer file.Close() + + entries := map[string]domainPolicyEntry{} + var previous string + scanner := bufio.NewScanner(file) + for line := 1; scanner.Scan(); line++ { + host := strings.TrimSpace(scanner.Text()) + if host == "" || strings.HasPrefix(host, "#") { + continue + } + if host != strings.ToLower(host) { + return nil, fmt.Errorf("%s:%d: hostname must be lowercase: %q", rel, line, host) + } + if err := validatePolicyHostname(host); err != nil { + return nil, fmt.Errorf("%s:%d: %w", rel, line, err) + } + if previous != "" && host <= previous { + return nil, fmt.Errorf("%s:%d: hostnames must be unique and sorted: %q", rel, line, host) + } + entries[host] = domainPolicyEntry{Host: host, File: rel, Line: line} + previous = host + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("read domain allowlist %s: %w", rel, err) + } + if len(entries) == 0 { + return nil, fmt.Errorf("%s: domain list must not be empty", rel) + } + return entries, nil +} + +func validatePolicyHostname(host string) error { + if len(host) > 253 || !strings.Contains(host, ".") || strings.HasSuffix(host, ".") { + return fmt.Errorf("invalid exact hostname %q", host) + } + labels := strings.Split(host, ".") + for _, label := range labels { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return fmt.Errorf("invalid exact hostname %q", host) + } + for _, r := range label { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + continue + } + return fmt.Errorf("invalid exact hostname %q", host) + } + } + if !strings.ContainsAny(labels[len(labels)-1], "abcdefghijklmnopqrstuvwxyz") { + return fmt.Errorf("invalid exact hostname %q", host) + } + return nil +} diff --git a/lint/domaincontract/policy_test.go b/lint/domaincontract/policy_test.go new file mode 100644 index 000000000..88e4544d7 --- /dev/null +++ b/lint/domaincontract/policy_test.go @@ -0,0 +1,120 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "strings" + "testing" +) + +func TestLoadDomainPolicy(t *testing.T) { + root := t.TempDir() + writeFile(t, root, publicDomainsPath, "# public\napi.example.com\nwww.example.com\n") + writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n") + + policy, err := loadDomainPolicy(root) + if err != nil { + t.Fatal(err) + } + if len(policy.Public) != 2 || len(policy.Fixtures) != 1 { + t.Fatalf("unexpected policy sizes: public=%d fixtures=%d", len(policy.Public), len(policy.Fixtures)) + } + if policy.Public["api.example.com"].Line != 2 { + t.Fatalf("api.example.com line = %d, want 2", policy.Public["api.example.com"].Line) + } +} + +func TestLoadDomainPolicyRejectsInvalidLists(t *testing.T) { + tests := []struct { + name string + public string + fixtures string + want string + }{ + { + name: "uppercase", + public: "API.example.com\n", + fixtures: "fixture.example.com\n", + want: "must be lowercase", + }, + { + name: "unsorted", + public: "www.example.com\napi.example.com\n", + fixtures: "fixture.example.com\n", + want: "unique and sorted", + }, + { + name: "duplicate", + public: "api.example.com\napi.example.com\n", + fixtures: "fixture.example.com\n", + want: "unique and sorted", + }, + { + name: "wildcard", + public: "*.example.com\n", + fixtures: "fixture.example.com\n", + want: "invalid exact hostname", + }, + { + name: "scheme", + public: "https://example.com\n", + fixtures: "fixture.example.com\n", + want: "invalid exact hostname", + }, + { + name: "path", + public: "api.example.com/v1\n", + fixtures: "fixture.example.com\n", + want: "invalid exact hostname", + }, + { + name: "port", + public: "api.example.com:443\n", + fixtures: "fixture.example.com\n", + want: "invalid exact hostname", + }, + { + name: "cross-list duplicate", + public: "api.example.com\n", + fixtures: "api.example.com\n", + want: "already listed", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + writeFile(t, root, publicDomainsPath, tc.public) + writeFile(t, root, fixtureDomainsPath, tc.fixtures) + _, err := loadDomainPolicy(root) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("loadDomainPolicy() error = %v, want substring %q", err, tc.want) + } + }) + } +} + +func TestReservedExampleHostname(t *testing.T) { + for _, host := range []string{ + "example.com", + "example.net", + "example.org", + "example.test", + "docs.example", + "missing.invalid", + "service.localhost", + } { + if !isReservedExampleHostname(host) { + t.Errorf("%q should be a reserved example hostname", host) + } + } + for _, host := range []string{ + "attacker.example.com", + "example.dev", + "private.corp.internal", + } { + if isReservedExampleHostname(host) { + t.Errorf("%q must still require policy approval", host) + } + } +} diff --git a/lint/domaincontract/scan.go b/lint/domaincontract/scan.go index ba2776de1..521e97751 100644 --- a/lint/domaincontract/scan.go +++ b/lint/domaincontract/scan.go @@ -1,8 +1,8 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -// Package domaincontract guards the Go CLI against direct reuse of the current -// resolver-owned host FQDNs outside core.ResolveEndpoints. +// Package domaincontract guards resolver ownership and rejects newly introduced +// static Go hostnames that are not covered by the repository domain policy. package domaincontract import ( @@ -11,6 +11,7 @@ import ( "go/token" "io/fs" "path/filepath" + "sort" "strconv" "strings" @@ -75,10 +76,40 @@ func skipDir(name string) bool { return false } -// ScanRepo walks production .go files under root and flags string literals -// containing a forbidden resolver host outside the allowlist. Comments and -// _test.go files are not scanned. +// ScanRepo runs the resolver-owned endpoint guard and a full repository domain +// inventory. CI should use ScanRepoWithOptions with a changed-from revision so +// historical unapproved domains are not attributed to an unrelated change. func ScanRepo(root string) ([]lintapi.Violation, error) { + return ScanRepoWithOptions(root, ScanOptions{}) +} + +type ScanOptions struct { + ChangedFrom string +} + +func ScanRepoWithOptions(root string, opts ScanOptions) ([]lintapi.Violation, error) { + out, err := scanHardcodedEndpoints(root) + if err != nil { + return nil, err + } + domainViolations, err := scanUnapprovedDomains(root, opts) + if err != nil { + return nil, err + } + out = append(out, domainViolations...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].File != out[j].File { + return out[i].File < out[j].File + } + if out[i].Line != out[j].Line { + return out[i].Line < out[j].Line + } + return out[i].Rule < out[j].Rule + }) + return out, nil +} + +func scanHardcodedEndpoints(root string) ([]lintapi.Violation, error) { var out []lintapi.Violation err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { if err != nil { diff --git a/lint/domaincontract/unapproved.go b/lint/domaincontract/unapproved.go new file mode 100644 index 000000000..db2badfdf --- /dev/null +++ b/lint/domaincontract/unapproved.go @@ -0,0 +1,911 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "fmt" + "go/ast" + "go/constant" + "go/parser" + "go/token" + "go/types" + "net" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "unicode" + + "github.com/larksuite/cli/lint/lintapi" + "golang.org/x/tools/go/packages" +) + +const ( + unapprovedDomainRule = "unapproved-domain" + unusedDomainRule = "domain-allowlist-unused" + incompleteDomainRule = "domain-scan-incomplete" +) + +type typedGoFile struct { + File *ast.File + Fset *token.FileSet + Info *types.Info +} + +type domainEvidence struct { + Host string + Kind string + Expr ast.Expr +} + +type evidenceKey struct { + Host string + Start, End token.Pos +} + +type fileDomainScan struct { + File *ast.File + Fset *token.FileSet + Info *types.Info + Evidence []domainEvidence + TypeInfoRequired []ast.Expr + seen map[evidenceKey]bool + parents map[ast.Node]ast.Node +} + +type collectionCompositeKind uint8 + +const ( + notCollectionComposite collectionCompositeKind = iota + sequenceComposite + mapComposite +) + +type hostnameFieldID struct { + Type string + Field string +} + +var nonNetworkHostnameFields = map[hostnameFieldID]bool{ + {Type: "github.com/larksuite/cli/events/im.CardActionTriggerOutput", Field: "Host"}: true, + {Type: "github.com/larksuite/cli/internal/cmdmeta.Meta", Field: "Domain"}: true, +} + +func scanUnapprovedDomains(root string, opts ScanOptions) ([]lintapi.Violation, error) { + root, err := filepath.Abs(root) + if err != nil { + return nil, fmt.Errorf("resolve repository root: %w", err) + } + publicPath := filepath.Join(root, filepath.FromSlash(publicDomainsPath)) + if _, err := os.Stat(publicPath); err != nil { + if os.IsNotExist(err) { + if _, goModErr := os.Stat(filepath.Join(root, "go.mod")); os.IsNotExist(goModErr) { + return nil, nil + } + } + return nil, fmt.Errorf("domain policy unavailable: %w", err) + } + policy, err := loadDomainPolicy(root) + if err != nil { + return nil, err + } + added, err := changedGoLineRanges(root, opts.ChangedFrom) + if err != nil { + return nil, err + } + typed, typeLoadErr := loadTypedGoFiles(root) + goFiles, err := trackedGoFiles(root) + if err != nil { + return nil, err + } + + observedPublic := map[string]bool{} + observedFixtures := map[string]bool{} + inventoryComplete := typeLoadErr == nil + var out []lintapi.Violation + parseFailureReported := false + typeInfoGapReported := false + for _, rel := range goFiles { + path := filepath.Join(root, filepath.FromSlash(rel)) + parsedFset := token.NewFileSet() + parsedFile, parseErr := parser.ParseFile(parsedFset, path, nil, 0) + if parseErr != nil { + inventoryComplete = false + if opts.ChangedFrom == "" { + out = append(out, incompleteDomainViolation(rel, parseErr)) + parseFailureReported = true + } else if _, changed := added[rel]; changed { + out = append(out, incompleteDomainViolation(rel, parseErr)) + parseFailureReported = true + } + continue + } + tf, ok := typed[filepath.Clean(path)] + if !ok { + tf = typedGoFile{File: parsedFile, Fset: parsedFset} + } + + scan := newFileDomainScan(tf) + scan.collectSemanticEvidence() + scan.collectAbsoluteURLEvidence() + if len(scan.TypeInfoRequired) > 0 { + // Inventory completeness is a property of the whole HEAD. Whether + // this PR owns an incomplete-scan diagnostic is decided separately + // by the added-line intersection below. + inventoryComplete = false + } + for _, expr := range scan.TypeInfoRequired { + start := tf.Fset.Position(expr.Pos()).Line + end := tf.Fset.Position(expr.End()).Line + line := start + if opts.ChangedFrom != "" { + var intersects bool + line, intersects = firstAddedLineInSpan(added[rel], start, end) + if !intersects { + continue + } + } + typeInfoGapReported = true + out = append(out, incompleteDomainViolationAt( + rel, + line, + fmt.Errorf("Go type information unavailable for hostname-oriented field evidence"), + )) + break + } + fixture := isDomainFixturePath(rel) + // The detector's own policy literals and contract corpus may be + // scanned, but they cannot justify keeping an allowlist entry. + policyOwner := strings.HasPrefix(rel, "lint/domaincontract/") + for _, evidence := range scan.Evidence { + if isReservedExampleHostname(evidence.Host) { + continue + } + if _, ok := policy.Public[evidence.Host]; ok { + if !fixture && !policyOwner { + observedPublic[evidence.Host] = true + } + continue + } + if _, ok := policy.Fixtures[evidence.Host]; ok && fixture { + if !policyOwner { + observedFixtures[evidence.Host] = true + } + continue + } + start := tf.Fset.Position(evidence.Expr.Pos()).Line + end := tf.Fset.Position(evidence.Expr.End()).Line + line := start + if opts.ChangedFrom != "" { + var intersects bool + line, intersects = firstAddedLineInSpan(added[rel], start, end) + if !intersects { + continue + } + } + suggestion := "remove the hostname or replace it with an approved public endpoint; " + + "public allowlist additions require evidence and CODEOWNER approval" + if _, fixtureOnly := policy.Fixtures[evidence.Host]; fixtureOnly && !fixture { + suggestion = "remove the fixture-only hostname or move this use into an approved fixture scope; " + + "fixture entries are not approved for production Go code or skills" + } + out = append(out, lintapi.Violation{ + Rule: unapprovedDomainRule, + Action: lintapi.ActionReject, + File: rel, + Line: line, + Message: fmt.Sprintf( + "unapproved hostname %q found in %s", + evidence.Host, + evidence.Kind, + ), + Suggestion: suggestion, + }) + } + } + + // A syntax error is also surfaced by go/packages. Prefer the file-specific + // parse diagnostic when one was already reported; otherwise make a + // repository-wide type-loading failure explicit instead of silently + // continuing without the type information required by field evidence. + if typeLoadErr != nil && !parseFailureReported && !typeInfoGapReported { + out = append(out, incompleteDomainViolation("go.mod", typeLoadErr)) + } + + if inventoryComplete { + for host, entry := range policy.Public { + if !observedPublic[host] { + out = append(out, unusedDomainViolation(entry)) + } + } + for host, entry := range policy.Fixtures { + if !observedFixtures[host] { + out = append(out, unusedDomainViolation(entry)) + } + } + } + return out, nil +} + +func trackedGoFiles(root string) ([]string, error) { + out, err := gitCommandOutput(root, "ls-files", "-z", "--", "*.go") + if err != nil { + return nil, fmt.Errorf("list tracked Go files: %w", err) + } + var files []string + for _, raw := range strings.Split(string(out), "\x00") { + if raw == "" { + continue + } + rel := filepath.ToSlash(raw) + if strings.HasPrefix(rel, "vendor/") || strings.HasPrefix(rel, "node_modules/") { + continue + } + files = append(files, rel) + } + return files, nil +} + +func loadTypedGoFiles(root string) (map[string]typedGoFile, error) { + moduleDirs, err := trackedGoModuleDirs(root) + if err != nil { + return nil, err + } + out := map[string]typedGoFile{} + var firstLoadErr error + var loadErrCount int + for _, moduleDir := range moduleDirs { + moduleRoot := root + if moduleDir != "." { + moduleRoot = filepath.Join(root, filepath.FromSlash(moduleDir)) + } + files, err := loadTypedGoModule(moduleRoot) + for path, file := range files { + out[path] = file + } + if err != nil { + loadErrCount++ + if firstLoadErr == nil { + firstLoadErr = err + } + } + } + if loadErrCount == 1 { + return out, firstLoadErr + } + if loadErrCount > 1 { + return out, fmt.Errorf("%w (and %d more module errors)", firstLoadErr, loadErrCount-1) + } + return out, nil +} + +func trackedGoModuleDirs(root string) ([]string, error) { + raw, err := gitCommandOutput(root, "ls-files", "-z") + if err != nil { + return nil, fmt.Errorf("list tracked Go modules: %w", err) + } + var dirs []string + for _, path := range strings.Split(string(raw), "\x00") { + path = filepath.ToSlash(path) + if path != "go.mod" && !strings.HasSuffix(path, "/go.mod") { + continue + } + dir := filepath.ToSlash(filepath.Dir(path)) + dirs = append(dirs, dir) + } + return dirs, nil +} + +func loadTypedGoModule(moduleRoot string) (map[string]typedGoFile, error) { + fset := token.NewFileSet() + cfg := &packages.Config{ + Mode: packages.NeedName | + packages.NeedFiles | + packages.NeedCompiledGoFiles | + packages.NeedImports | + packages.NeedDeps | + packages.NeedTypes | + packages.NeedSyntax | + packages.NeedTypesInfo, + Dir: moduleRoot, + Fset: fset, + Tests: true, + } + pkgs, err := packages.Load(cfg, "./...") + if err != nil { + return nil, fmt.Errorf("load Go type information: %w", err) + } + out := map[string]typedGoFile{} + var firstPackageErr string + var packageErrCount int + packages.Visit(pkgs, nil, func(pkg *packages.Package) { + if pkg == nil { + return + } + for _, pkgErr := range pkg.Errors { + packageErrCount++ + if firstPackageErr == "" { + firstPackageErr = pkgErr.Error() + } + } + if pkg.TypesInfo == nil || pkg.Fset == nil { + return + } + for i, file := range pkg.Syntax { + if i >= len(pkg.CompiledGoFiles) { + break + } + path := filepath.Clean(pkg.CompiledGoFiles[i]) + if _, exists := out[path]; exists { + continue + } + out[path] = typedGoFile{File: file, Fset: pkg.Fset, Info: pkg.TypesInfo} + } + }) + if packageErrCount == 1 { + return out, fmt.Errorf("load Go type information: %s", firstPackageErr) + } + if packageErrCount > 1 { + return out, fmt.Errorf( + "load Go type information: %s (and %d more package errors)", + firstPackageErr, + packageErrCount-1, + ) + } + return out, nil +} + +func newFileDomainScan(file typedGoFile) *fileDomainScan { + return &fileDomainScan{ + File: file.File, + Fset: file.Fset, + Info: file.Info, + seen: map[evidenceKey]bool{}, + parents: astParentMap(file.File), + } +} + +func (s *fileDomainScan) collectSemanticEvidence() { + ast.Inspect(s.File, func(node ast.Node) bool { + switch n := node.(type) { + case *ast.AssignStmt: + if len(n.Lhs) != len(n.Rhs) { + return true + } + for i, lhs := range n.Lhs { + if s.Info == nil && + potentialHostnameSelectorTarget(lhs) && + s.hasStaticBareHostnameValue(n.Rhs[i]) { + s.requireTypeInfo(n.Rhs[i]) + } + if index, ok := stripParens(lhs).(*ast.IndexExpr); ok { + switch { + case s.isHostnameTarget(index.X): + s.addMapPair(index.Index, n.Rhs[i]) + case s.isHostnameMapKey(index.Index): + s.addHostValue(n.Rhs[i], "host assignment") + } + continue + } + if s.isHostnameTarget(lhs) { + s.addHostValue(n.Rhs[i], "host assignment") + } + } + case *ast.ValueSpec: + if len(n.Names) != len(n.Values) { + return true + } + for i, name := range n.Names { + if isHostnameSemanticName(name.Name) { + s.addHostValue(n.Values[i], "host assignment") + } + } + case *ast.KeyValueExpr: + if s.Info == nil && s.keyValueNeedsTypeInfo(n) { + s.requireTypeInfo(n.Value) + } + if s.isHostnameKeyValue(n) { + s.addHostValue(n.Value, "host assignment") + } + } + return true + }) +} + +func (s *fileDomainScan) requireTypeInfo(expr ast.Expr) { + for _, existing := range s.TypeInfoRequired { + if existing.Pos() == expr.Pos() && existing.End() == expr.End() { + return + } + } + s.TypeInfoRequired = append(s.TypeInfoRequired, expr) +} + +func (s *fileDomainScan) hasStaticBareHostnameValue(expr ast.Expr) bool { + value, ok := staticStringValue(expr, s.Info, nil) + if !ok { + return false + } + host, ok := semanticHostname(value) + return ok && !isReservedExampleHostname(host) +} + +func (s *fileDomainScan) keyValueNeedsTypeInfo(pair *ast.KeyValueExpr) bool { + composite, ok := s.parents[pair].(*ast.CompositeLit) + if !ok { + return false + } + if _, explicitMap := composite.Type.(*ast.MapType); explicitMap { + return false + } + key, ok := pair.Key.(*ast.Ident) + return ok && isHostnameSemanticName(key.Name) && s.hasStaticBareHostnameValue(pair.Value) +} + +func potentialHostnameSelectorTarget(expr ast.Expr) bool { + switch n := stripParens(expr).(type) { + case *ast.SelectorExpr: + return isHostnameSemanticName(n.Sel.Name) + case *ast.StarExpr: + return potentialHostnameSelectorTarget(n.X) + case *ast.IndexExpr: + return potentialHostnameSelectorTarget(n.X) + default: + return false + } +} + +func (s *fileDomainScan) collectAbsoluteURLEvidence() { + ast.Inspect(s.File, func(node ast.Node) bool { + expr, ok := node.(ast.Expr) + if !ok { + return true + } + if ident, ok := expr.(*ast.Ident); ok && s.Info != nil && s.Info.Defs[ident] != nil { + // A declaration name may carry the constant value in types.Info, + // but it is not a second source expression. + return true + } + value, ok := staticStringValue(expr, s.Info, nil) + if !ok { + return true + } + if s.hasStaticStringContainer(expr) { + return true + } + host, ok := absoluteURLHostname(value) + if ok { + s.addEvidence(host, "absolute URL", expr) + } + return true + }) +} + +func (s *fileDomainScan) hasStaticStringContainer(expr ast.Expr) bool { + parent, ok := s.parents[expr].(ast.Expr) + if !ok { + return false + } + switch parent.(type) { + case *ast.BinaryExpr, *ast.ParenExpr: + _, ok := staticStringValue(parent, s.Info, nil) + return ok + default: + return false + } +} + +func (s *fileDomainScan) addHostValue(expr ast.Expr, kind string) { + expr = stripParens(expr) + if composite, ok := expr.(*ast.CompositeLit); ok { + switch s.collectionCompositeKind(composite) { + case sequenceComposite: + for _, element := range composite.Elts { + if valueExpr, ok := element.(ast.Expr); ok { + s.addHostValue(valueExpr, "host collection") + } + } + case mapComposite: + for _, element := range composite.Elts { + pair, ok := element.(*ast.KeyValueExpr) + if !ok { + continue + } + keyExpr, ok := pair.Key.(ast.Expr) + if !ok { + continue + } + s.addMapPair(keyExpr, pair.Value) + } + default: + if s.Info == nil { + s.requireTypeInfoForUnclassifiedCollection(composite) + } + return + } + return + } + if evidence, ok := s.hostnameEvidence(expr, kind); ok { + s.addEvidence(evidence.Host, evidence.Kind, evidence.Expr) + } +} + +func (s *fileDomainScan) requireTypeInfoForUnclassifiedCollection(composite *ast.CompositeLit) { + for _, element := range composite.Elts { + if pair, ok := element.(*ast.KeyValueExpr); ok { + keyExpr, ok := pair.Key.(ast.Expr) + if !ok { + continue + } + keyIsHost := s.hasStaticBareHostnameValue(keyExpr) + valueIsHost := s.hasStaticBareHostnameValue(pair.Value) + if keyIsHost == valueIsHost { + continue + } + if keyIsHost { + s.requireTypeInfo(keyExpr) + } else { + s.requireTypeInfo(pair.Value) + } + continue + } + valueExpr, ok := element.(ast.Expr) + if ok && s.hasStaticBareHostnameValue(valueExpr) { + s.requireTypeInfo(valueExpr) + } + } +} + +// addMapPair reports a map side only when it is the sole hostname-shaped +// static value. A semantic map name does not establish whether a string map +// is hostname->metadata or alias->hostname, so reporting both sides would turn +// filenames such as client.pem into blocking hostname evidence. +func (s *fileDomainScan) addMapPair(key, value ast.Expr) { + keyEvidence, keyOK := s.hostnameEvidence(key, "host collection") + valueEvidence, valueOK := s.hostnameEvidence(value, "host collection") + if keyOK == valueOK { + return + } + if keyOK { + s.addEvidence(keyEvidence.Host, keyEvidence.Kind, keyEvidence.Expr) + return + } + s.addEvidence(valueEvidence.Host, valueEvidence.Kind, valueEvidence.Expr) +} + +func (s *fileDomainScan) hostnameEvidence(expr ast.Expr, kind string) (domainEvidence, bool) { + expr = stripParens(expr) + value, ok := staticStringValue(expr, s.Info, nil) + if !ok { + return domainEvidence{}, false + } + if host, ok := absoluteURLHostname(value); ok { + return domainEvidence{Host: host, Kind: "absolute URL", Expr: expr}, true + } + if host, ok := semanticHostname(value); ok { + return domainEvidence{Host: host, Kind: kind, Expr: expr}, true + } + return domainEvidence{}, false +} + +func (s *fileDomainScan) collectionCompositeKind(expr *ast.CompositeLit) collectionCompositeKind { + if s.Info != nil { + if tv, ok := s.Info.Types[expr]; ok && tv.Type != nil { + switch tv.Type.Underlying().(type) { + case *types.Array, *types.Slice: + return sequenceComposite + case *types.Map: + return mapComposite + } + } + } + switch expr.Type.(type) { + case *ast.ArrayType: + return sequenceComposite + case *ast.MapType: + return mapComposite + default: + return notCollectionComposite + } +} + +func (s *fileDomainScan) addEvidence(host, kind string, expr ast.Expr) { + key := evidenceKey{Host: host, Start: expr.Pos(), End: expr.End()} + if s.seen[key] { + return + } + s.seen[key] = true + s.Evidence = append(s.Evidence, domainEvidence{Host: host, Kind: kind, Expr: expr}) +} + +func staticStringValue(expr ast.Expr, info *types.Info, seen map[*ast.Object]bool) (string, bool) { + if info != nil { + if tv, ok := info.Types[expr]; ok && tv.Value != nil && tv.Value.Kind() == constant.String { + return constant.StringVal(tv.Value), true + } + } + switch n := expr.(type) { + case *ast.BasicLit: + if n.Kind != token.STRING { + return "", false + } + value, err := strconv.Unquote(n.Value) + return value, err == nil + case *ast.ParenExpr: + return staticStringValue(n.X, info, seen) + case *ast.BinaryExpr: + if n.Op != token.ADD { + return "", false + } + left, ok := staticStringValue(n.X, info, seen) + if !ok { + return "", false + } + right, ok := staticStringValue(n.Y, info, seen) + if !ok { + return "", false + } + return left + right, true + case *ast.Ident: + if info != nil { + if obj := info.ObjectOf(n); obj != nil { + if c, ok := obj.(*types.Const); ok { + if c.Val().Kind() == constant.String { + return constant.StringVal(c.Val()), true + } + } + } + } + if n.Obj == nil || n.Obj.Kind != ast.Con { + return "", false + } + if seen == nil { + seen = map[*ast.Object]bool{} + } + if seen[n.Obj] { + return "", false + } + seen[n.Obj] = true + defer delete(seen, n.Obj) + spec, ok := n.Obj.Decl.(*ast.ValueSpec) + if !ok { + return "", false + } + for i, name := range spec.Names { + if name.Name == n.Name && i < len(spec.Values) { + return staticStringValue(spec.Values[i], info, seen) + } + } + } + return "", false +} + +func absoluteURLHostname(value string) (string, bool) { + value = strings.TrimSpace(value) + parsed, err := url.Parse(value) + if err != nil || parsed.Host == "" { + return "", false + } + switch strings.ToLower(parsed.Scheme) { + case "http", "https", "ws", "wss": + default: + return "", false + } + return normalizeCandidateHostname(parsed.Hostname()) +} + +func semanticHostname(value string) (string, bool) { + value = strings.TrimSpace(value) + if value == "" || strings.ContainsAny(value, `/\?#@`) || strings.ContainsAny(value, " \t\r\n") { + return "", false + } + parsed, err := url.Parse("//" + value) + if err != nil || parsed.Host == "" || parsed.Path != "" { + return "", false + } + return normalizeCandidateHostname(parsed.Hostname()) +} + +func normalizeCandidateHostname(host string) (string, bool) { + host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") + if host == "" || !strings.Contains(host, ".") || net.ParseIP(host) != nil { + return "", false + } + labels := strings.Split(host, ".") + for _, label := range labels { + if label == "" || strings.HasPrefix(label, "-") || strings.HasSuffix(label, "-") { + return "", false + } + for _, r := range label { + if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' { + continue + } + return "", false + } + } + return host, true +} + +func (s *fileDomainScan) isHostnameTarget(expr ast.Expr) bool { + switch n := stripParens(expr).(type) { + case *ast.Ident: + return isHostnameSemanticName(n.Name) + case *ast.SelectorExpr: + return s.isHostnameSelector(n) + case *ast.StarExpr: + return s.isHostnameTarget(n.X) + default: + return false + } +} + +func (s *fileDomainScan) isHostnameKeyValue(pair *ast.KeyValueExpr) bool { + composite, ok := s.parents[pair].(*ast.CompositeLit) + if !ok { + return false + } + switch s.collectionCompositeKind(composite) { + case mapComposite: + key, ok := pair.Key.(ast.Expr) + return ok && s.isHostnameMapKey(key) + case notCollectionComposite: + ident, ok := pair.Key.(*ast.Ident) + return ok && s.isHostnameStructField(composite, ident.Name) + default: + return false + } +} + +func (s *fileDomainScan) isHostnameMapKey(expr ast.Expr) bool { + value, ok := staticStringValue(expr, s.Info, nil) + return ok && isHostnameSemanticName(value) +} + +func (s *fileDomainScan) isHostnameSelector(selector *ast.SelectorExpr) bool { + if s.Info == nil || !isHostnameSemanticName(selector.Sel.Name) { + return false + } + selection := s.Info.Selections[selector] + if selection == nil || selection.Kind() != types.FieldVal { + return false + } + return !nonNetworkHostnameFields[hostnameFieldID{ + Type: namedTypeID(selection.Recv()), + Field: selector.Sel.Name, + }] +} + +func (s *fileDomainScan) isHostnameStructField(composite *ast.CompositeLit, field string) bool { + if s.Info == nil || !isHostnameSemanticName(field) { + return false + } + typeID := namedTypeID(s.Info.TypeOf(composite)) + if typeID == "" { + return false + } + return !nonNetworkHostnameFields[hostnameFieldID{Type: typeID, Field: field}] +} + +func namedTypeID(typ types.Type) string { + for { + switch t := typ.(type) { + case *types.Pointer: + typ = t.Elem() + case *types.Named: + obj := t.Obj() + if obj == nil || obj.Pkg() == nil { + return "" + } + return obj.Pkg().Path() + "." + obj.Name() + default: + return "" + } + } +} + +func isHostnameSemanticName(name string) bool { + lower := strings.ToLower(name) + switch lower { + case "host", "hosts", "hostname", "hostnames", "domain", "domains": + return true + } + for _, marker := range []string{ + "HostBy", "HostsBy", "HostnameBy", "HostnamesBy", "DomainBy", "DomainsBy", + } { + if i := strings.Index(name, marker); i >= 0 { + end := i + len(marker) + if end < len(name) && unicode.IsUpper(rune(name[end])) { + return true + } + } + } + for _, prefix := range []string{ + "hostBy", "hostsBy", "hostnameBy", "hostnamesBy", "domainBy", "domainsBy", + } { + if strings.HasPrefix(name, prefix) && + len(name) > len(prefix) && + unicode.IsUpper(rune(name[len(prefix)])) { + return true + } + } + if i := strings.LastIndexAny(name, "_-"); i >= 0 { + return isHostnameSemanticName(name[i+1:]) + } + for _, suffix := range []string{"Hostnames", "Hostname", "Domains", "Domain", "Hosts", "Host"} { + if strings.HasSuffix(name, suffix) && len(name) > len(suffix) { + return true + } + } + return false +} + +func stripParens(expr ast.Expr) ast.Expr { + for { + paren, ok := expr.(*ast.ParenExpr) + if !ok { + return expr + } + expr = paren.X + } +} + +func astParentMap(root ast.Node) map[ast.Node]ast.Node { + parents := map[ast.Node]ast.Node{} + var stack []ast.Node + ast.Inspect(root, func(node ast.Node) bool { + if node == nil { + stack = stack[:len(stack)-1] + return false + } + if len(stack) > 0 { + parents[node] = stack[len(stack)-1] + } + stack = append(stack, node) + return true + }) + return parents +} + +func isDomainFixturePath(rel string) bool { + rel = filepath.ToSlash(rel) + if strings.HasPrefix(rel, "skills/") { + return false + } + if strings.HasSuffix(rel, "_test.go") || strings.HasPrefix(rel, "tests/") { + return true + } + for _, part := range strings.Split(rel, "/") { + if part == "testdata" { + return true + } + } + return false +} + +func unusedDomainViolation(entry domainPolicyEntry) lintapi.Violation { + return lintapi.Violation{ + Rule: unusedDomainRule, + Action: lintapi.ActionReject, + File: entry.File, + Line: entry.Line, + Message: fmt.Sprintf("domain allowlist entry %q has no in-scope Go reference", entry.Host), + Suggestion: "remove the unused entry; allowlist entries must be justified by a current in-scope reference", + } +} + +func incompleteDomainViolation(file string, err error) lintapi.Violation { + return incompleteDomainViolationAt(file, 1, err) +} + +func incompleteDomainViolationAt(file string, line int, err error) lintapi.Violation { + return lintapi.Violation{ + Rule: incompleteDomainRule, + Action: lintapi.ActionReject, + File: file, + Line: line, + Message: "domain scan incomplete: " + err.Error(), + Suggestion: "fix the Go parse or type-loading error so hostname analysis can complete", + } +} diff --git a/lint/domaincontract/unapproved_repo_test.go b/lint/domaincontract/unapproved_repo_test.go new file mode 100644 index 000000000..07d8ec4e0 --- /dev/null +++ b/lint/domaincontract/unapproved_repo_test.go @@ -0,0 +1,462 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/lint/lintapi" +) + +func gitTestCommand(t *testing.T, root string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = root + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) +} + +func setupDomainDiffRepo(t *testing.T, target string) (root, base string) { + t.Helper() + root = t.TempDir() + writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n") + writeFile(t, root, publicDomainsPath, "# public\npublic.example.com\n") + writeFile(t, root, fixtureDomainsPath, "# fixtures\nfixture.example.com\n") + writeFile(t, root, "policy_refs.go", "package sample\n\nvar APIHost = \"public.example.com\"\n") + writeFile(t, root, "policy_refs_test.go", "package sample\n\nvar FixtureHost = \"fixture.example.com\"\n") + writeFile(t, root, "target.go", target) + + gitTestCommand(t, root, "init", "-q") + gitTestCommand(t, root, "config", "user.name", "Domain Contract Test") + gitTestCommand(t, root, "config", "user.email", "domain-contract@example.com") + gitTestCommand(t, root, "add", ".") + gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", "base") + return root, gitTestCommand(t, root, "rev-parse", "HEAD") +} + +func commitDomainDiff(t *testing.T, root, message string) { + t.Helper() + gitTestCommand(t, root, "add", "-A") + gitTestCommand(t, root, "-c", "commit.gpgsign=false", "commit", "-qm", message) +} + +func violationsForRule(vs []lintapi.Violation, rule string) []lintapi.Violation { + var out []lintapi.Violation + for _, v := range vs { + if v.Rule == rule { + out = append(out, v) + } + } + return out +} + +func scanDomainDiff(t *testing.T, root, base string) []lintapi.Violation { + t.Helper() + vs, err := ScanRepoWithOptions(root, ScanOptions{ChangedFrom: base}) + if err != nil { + t.Fatal(err) + } + return vs +} + +func TestUnapprovedDomainDiffContract(t *testing.T) { + t.Run("new PR 1975 case", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nvar APIHost = \"internal-api-drive-stream.larkoffice.com\"\n") + commitDomainDiff(t, root, "add internal host") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "internal-api-drive-stream.larkoffice.com") { + t.Fatalf("violations = %+v, want PR 1975 hostname", got) + } + }) + + t.Run("hostname field in nested Go module", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "nested/go.mod", "module example.com/nested\n\ngo 1.23.0\n") + writeFile(t, root, "nested/target.go", + "package nested\n\ntype Config struct{ Host string }\n\n"+ + "var config = Config{Host: \"private.corp.internal\"}\n") + commitDomainDiff(t, root, "add nested module hostname") + + all := scanDomainDiff(t, root, base) + got := violationsForRule(all, unapprovedDomainRule) + if len(got) != 1 || filepath.ToSlash(got[0].File) != "nested/target.go" || + !strings.Contains(got[0].Message, "private.corp.internal") { + t.Fatalf("violations = %+v, want nested-module hostname rejection", got) + } + if incomplete := violationsForRule(all, incompleteDomainRule); len(incomplete) != 0 { + t.Fatalf("nested module must have complete type information: %+v", incomplete) + } + }) + + t.Run("changed excluded field reports incomplete scan", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "excluded.go", + "//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+ + "type Config struct{ Host string }\n\n"+ + "var config = Config{Host: \"private.corp.internal\"}\n") + commitDomainDiff(t, root, "add excluded hostname field") + + all := scanDomainDiff(t, root, base) + got := violationsForRule(all, incompleteDomainRule) + if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 { + t.Fatalf("violations = %+v, want changed field scan-incomplete at line 7", got) + } + if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 { + t.Fatalf("untyped field must not produce an unverified hostname finding: %+v", unapproved) + } + }) + + t.Run("changed excluded selector reports incomplete scan", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "excluded.go", + "//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+ + "type Config struct{ Host string }\n\n"+ + "func configure(config *Config) { config.Host = \"private.corp.internal\" }\n") + commitDomainDiff(t, root, "add excluded hostname selector") + + got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule) + if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 7 { + t.Fatalf("violations = %+v, want changed selector scan-incomplete at line 7", got) + } + }) + + t.Run("changed excluded named slice reports incomplete scan", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "excluded.go", + "//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+ + "type HostList []string\n\n"+ + "var AllowedHosts = HostList{\n\t\"attacker.zip\",\n}\n") + commitDomainDiff(t, root, "add excluded hostname slice") + + all := scanDomainDiff(t, root, base) + got := violationsForRule(all, incompleteDomainRule) + if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 { + t.Fatalf("violations = %+v, want named-slice scan-incomplete at line 8", got) + } + if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 { + t.Fatalf("untyped named slice must not produce an unverified hostname finding: %+v", unapproved) + } + }) + + t.Run("changed excluded named map reports incomplete scan", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "excluded.go", + "//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+ + "type HostSet map[string]struct{}\n\n"+ + "var AllowedHosts = HostSet{\n\t\"attacker.zip\": {},\n}\n") + commitDomainDiff(t, root, "add excluded hostname map") + + all := scanDomainDiff(t, root, base) + got := violationsForRule(all, incompleteDomainRule) + if len(got) != 1 || filepath.Base(got[0].File) != "excluded.go" || got[0].Line != 8 { + t.Fatalf("violations = %+v, want named-map scan-incomplete at line 8", got) + } + if unapproved := violationsForRule(all, unapprovedDomainRule); len(unapproved) != 0 { + t.Fatalf("untyped named map must not produce an unverified hostname finding: %+v", unapproved) + } + }) + + t.Run("changed excluded unrelated code stays allowed", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "excluded.go", + "//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\nvar unrelated = 2\n") + commitDomainDiff(t, root, "add excluded unrelated code") + + if got := violationsForRule(scanDomainDiff(t, root, base), incompleteDomainRule); len(got) != 0 { + t.Fatalf("unrelated excluded code must not require hostname type information: %+v", got) + } + }) + + t.Run("new element in existing collection", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, + "package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n}\n") + writeFile(t, root, "target.go", + "package sample\n\nvar ExtraHosts = []string{\n\t\"public.example.com\",\n\t\"attacker.zip\",\n}\n") + commitDomainDiff(t, root, "add collection host") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") { + t.Fatalf("violations = %+v, want attacker.zip", got) + } + if got[0].Line != 5 { + t.Fatalf("violation line = %d, want 5", got[0].Line) + } + }) + + t.Run("multiline expression changed segment", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, + "package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"example.com\"\n") + writeFile(t, root, "target.go", + "package sample\n\nvar ExtraHost = \"private.corp.\" +\n\t\"internal\"\n") + commitDomainDiff(t, root, "change concatenated host") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") { + t.Fatalf("violations = %+v, want private.corp.internal", got) + } + if got[0].Line != 4 { + t.Fatalf("violation line = %d, want changed line 4", got[0].Line) + } + }) + + t.Run("unrelated change beside historical hostname", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, + "package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n") + writeFile(t, root, "target.go", + "package sample\n\nvar HistoricalHost = \"historical.private.internal\"\nvar unrelated = 1\n") + commitDomainDiff(t, root, "add unrelated value") + + if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 { + t.Fatalf("unexpected historical-domain violation: %+v", got) + } + }) + + t.Run("historical hostname expression changed", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, + "package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n") + writeFile(t, root, "target.go", + "package sample\n\nvar HistoricalHost = \"replacement.private.internal\"\n") + commitDomainDiff(t, root, "change historical host") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "replacement.private.internal") { + t.Fatalf("violations = %+v, want replacement.private.internal", got) + } + }) + + t.Run("new assignment references existing constant", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, + "package sample\n\nconst existingConst = \"private.corp.internal\"\n") + writeFile(t, root, "target.go", + "package sample\n\nconst existingConst = \"private.corp.internal\"\nvar APIHost = existingConst\n") + commitDomainDiff(t, root, "use existing hostname constant") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "private.corp.internal") { + t.Fatalf("violations = %+v, want private.corp.internal", got) + } + if got[0].Line != 4 { + t.Fatalf("violation line = %d, want 4", got[0].Line) + } + }) + + t.Run("allowlisted hostname", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nvar BackupHost = \"public.example.com\"\n") + commitDomainDiff(t, root, "add public host") + + if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 { + t.Fatalf("unexpected public-domain violation: %+v", got) + } + }) + + t.Run("reserved example URL", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nfunc fakeValue() string { return \"https://example.test/resource\" }\n") + commitDomainDiff(t, root, "add safe example URL") + + if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 { + t.Fatalf("unexpected reserved-example violation: %+v", got) + } + }) + + t.Run("historical type gap suppresses unused policy diagnostics", func(t *testing.T) { + root, _ := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, publicDomainsPath, + "# public\nplatform.example.com\npublic.example.com\n") + writeFile(t, root, "excluded.go", + "//go:build domaincontract_never && !domaincontract_never\n\npackage sample\n\n"+ + "type Config struct{ Host string }\n\n"+ + "var config = Config{Host: \"platform.example.com\"}\n") + commitDomainDiff(t, root, "add historical platform hostname") + base := gitTestCommand(t, root, "rev-parse", "HEAD") + + writeFile(t, root, "target.go", "package sample\n\nvar unrelated = 2\n") + commitDomainDiff(t, root, "change unrelated code") + + all := scanDomainDiff(t, root, base) + if got := violationsForRule(all, incompleteDomainRule); len(got) != 0 { + t.Fatalf("historical type gap must not be attributed to this change: %+v", got) + } + if got := violationsForRule(all, unusedDomainRule); len(got) != 0 { + t.Fatalf("incomplete inventory must not produce unused-policy diagnostics: %+v", got) + } + }) + + t.Run("allowlist does not approve subdomains", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nvar BackupHost = \"evil.public.example.com\"\n") + commitDomainDiff(t, root, "add unapproved public subdomain") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "evil.public.example.com") { + t.Fatalf("violations = %+v, want evil.public.example.com", got) + } + }) + + t.Run("multi assignment pairs names and values", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, publicDomainsPath, + "# public\nopen.larksuite.com\npublic.example.com\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nvar APIHost, BackupHost = \"open.larksuite.com\", \"attacker.zip\"\n") + commitDomainDiff(t, root, "add multiple hosts") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "attacker.zip") { + t.Fatalf("violations = %+v, want only attacker.zip", got) + } + }) + + t.Run("IDN hostname is rejected", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nvar BackupHost = \"例子.公司.cn\"\n") + commitDomainDiff(t, root, "add IDN hostname") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "例子.公司.cn") { + t.Fatalf("violations = %+v, want IDN hostname", got) + } + }) + + t.Run("fixture limited to test files", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", + "package sample\n\nvar unrelated = 1\nvar ProductionHost = \"fixture.example.com\"\n") + commitDomainDiff(t, root, "use fixture in production") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") { + t.Fatalf("violations = %+v, want production fixture rejection", got) + } + if !strings.Contains(got[0].Suggestion, "fixture-only hostname") || + strings.Contains(got[0].Suggestion, "public allowlist") { + t.Fatalf("suggestion = %q, want fixture-scope guidance", got[0].Suggestion) + } + }) + + t.Run("fixture accepted in test file", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "new_target_test.go", + "package sample\n\nvar BackupHost = \"fixture.example.com\"\n") + commitDomainDiff(t, root, "use fixture in test") + + if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 { + t.Fatalf("unexpected fixture-domain violation: %+v", got) + } + }) + + t.Run("fixture allowlist does not approve subdomains", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "new_target_test.go", + "package sample\n\nvar BackupHost = \"evil.fixture.example.com\"\n") + commitDomainDiff(t, root, "use unapproved fixture subdomain") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "evil.fixture.example.com") { + t.Fatalf("violations = %+v, want exact fixture match", got) + } + }) + + t.Run("fixture rejected in skills", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "skills/example/example_test.go", + "package example\n\nvar BackupHost = \"fixture.example.com\"\n") + commitDomainDiff(t, root, "use fixture in skill") + + got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "fixture.example.com") { + t.Fatalf("violations = %+v, want skill fixture rejection", got) + } + }) + + t.Run("pure rename", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, + "package sample\n\nvar HistoricalHost = \"historical.private.internal\"\n") + gitTestCommand(t, root, "mv", "target.go", "renamed.go") + commitDomainDiff(t, root, "rename file") + + if got := violationsForRule(scanDomainDiff(t, root, base), unapprovedDomainRule); len(got) != 0 { + t.Fatalf("unexpected rename violation: %+v", got) + } + }) +} + +func TestUnapprovedDomainPolicyAndFailurePaths(t *testing.T) { + t.Run("unused policy entry", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, publicDomainsPath, + "# public\npublic.example.com\nunused.example.com\n") + commitDomainDiff(t, root, "add unused policy") + + got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "unused.example.com") { + t.Fatalf("violations = %+v, want unused.example.com", got) + } + }) + + t.Run("public entry used only by fixture", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, publicDomainsPath, + "# public\npublic.example.com\ntest-only.example.com\n") + writeFile(t, root, "public_only_test.go", + "package sample\n\nvar BackupHost = \"test-only.example.com\"\n") + commitDomainDiff(t, root, "add test-only public policy") + + got := violationsForRule(scanDomainDiff(t, root, base), unusedDomainRule) + if len(got) != 1 || !strings.Contains(got[0].Message, "test-only.example.com") { + t.Fatalf("violations = %+v, want test-only.example.com", got) + } + }) + + t.Run("changed Go parse failure", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "target.go", "package sample\n\nfunc broken(\n") + commitDomainDiff(t, root, "break source") + + all := scanDomainDiff(t, root, base) + got := violationsForRule(all, incompleteDomainRule) + if len(got) != 1 || filepath.Base(got[0].File) != "target.go" { + t.Fatalf("violations = %+v, want target.go scan-incomplete", got) + } + if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 { + t.Fatalf("parse failure must not produce unreliable unused-policy diagnostics: %+v", unused) + } + }) + + t.Run("repository type loading failure", func(t *testing.T) { + root, base := setupDomainDiffRepo(t, "package sample\n\nvar unrelated = 1\n") + writeFile(t, root, "go.mod", "module example.com/domainfixture\n\ngo 1.23.0\n\n"+ + "require example.com/missing v0.0.0\n\nreplace example.com/missing => ./missing\n") + writeFile(t, root, "target.go", + "package sample\n\nimport _ \"example.com/missing\"\n\n"+ + "type Config struct{ Host string }\nvar config = Config{Host: \"malicious.corp.internal\"}\n") + commitDomainDiff(t, root, "break type loading") + + all := scanDomainDiff(t, root, base) + got := violationsForRule(all, incompleteDomainRule) + if len(got) != 1 || filepath.Base(got[0].File) != "go.mod" { + t.Fatalf("violations = %+v, want go.mod scan-incomplete", got) + } + if !strings.Contains(got[0].Message, "load Go type information") { + t.Fatalf("message = %q, want type-loading failure", got[0].Message) + } + if unused := violationsForRule(all, unusedDomainRule); len(unused) != 0 { + t.Fatalf("type-loading failure must not produce unreliable unused-policy diagnostics: %+v", unused) + } + }) +} diff --git a/lint/domaincontract/unapproved_test.go b/lint/domaincontract/unapproved_test.go new file mode 100644 index 000000000..1b671d7dd --- /dev/null +++ b/lint/domaincontract/unapproved_test.go @@ -0,0 +1,380 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package domaincontract + +import ( + "go/ast" + "go/parser" + "go/token" + "go/types" + "sort" + "testing" +) + +func scanDomainEvidence(t *testing.T, source string) []domainEvidence { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fixture.go", source, 0) + if err != nil { + t.Fatalf("parse fixture: %v\n%s", err, source) + } + scan := newFileDomainScan(typedGoFile{File: file, Fset: fset}) + scan.collectSemanticEvidence() + scan.collectAbsoluteURLEvidence() + sort.Slice(scan.Evidence, func(i, j int) bool { + if scan.Evidence[i].Host != scan.Evidence[j].Host { + return scan.Evidence[i].Host < scan.Evidence[j].Host + } + return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos() + }) + return scan.Evidence +} + +func scanTypedDomainEvidence(t *testing.T, source string) []domainEvidence { + t.Helper() + return scanTypedDomainEvidenceInPackage(t, "fixture", source) +} + +func scanTypedDomainEvidenceInPackage(t *testing.T, packagePath, source string) []domainEvidence { + t.Helper() + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "fixture.go", source, 0) + if err != nil { + t.Fatalf("parse fixture: %v\n%s", err, source) + } + info := &types.Info{ + Types: map[ast.Expr]types.TypeAndValue{}, + Defs: map[*ast.Ident]types.Object{}, + Uses: map[*ast.Ident]types.Object{}, + Selections: map[*ast.SelectorExpr]*types.Selection{}, + } + if _, err := (&types.Config{}).Check(packagePath, fset, []*ast.File{file}, info); err != nil { + t.Fatalf("type-check fixture: %v\n%s", err, source) + } + scan := newFileDomainScan(typedGoFile{File: file, Fset: fset, Info: info}) + scan.collectSemanticEvidence() + scan.collectAbsoluteURLEvidence() + sort.Slice(scan.Evidence, func(i, j int) bool { + if scan.Evidence[i].Host != scan.Evidence[j].Host { + return scan.Evidence[i].Host < scan.Evidence[j].Host + } + return scan.Evidence[i].Expr.Pos() < scan.Evidence[j].Expr.Pos() + }) + return scan.Evidence +} + +func evidenceHosts(evidence []domainEvidence) []string { + hosts := make([]string, 0, len(evidence)) + for _, item := range evidence { + hosts = append(hosts, item.Host) + } + return hosts +} + +func TestTypedAbsoluteURLDeclarationProducesOneFinding(t *testing.T) { + evidence := scanTypedDomainEvidence(t, + "package p\nconst DomainContractE2EURL = \"https://private.corp.internal/v1\"\n") + if got := evidenceHosts(evidence); len(got) != 1 || got[0] != "private.corp.internal" { + t.Fatalf("hosts = %v, want [private.corp.internal]", got) + } +} + +func TestGoDomainEvidenceTruePositives(t *testing.T) { + tests := []struct { + name string + source string + want []string + }{ + { + name: "PR 1975 Feishu assignment", + source: "package p\nfunc f() { host := \"internal-api-drive-stream.feishu.cn\"; _ = host }\n", + want: []string{"internal-api-drive-stream.feishu.cn"}, + }, + { + name: "PR 1975 Lark assignment", + source: "package p\nfunc f() { var host string; host = \"internal-api-drive-stream.larksuite.com\"; _ = host }\n", + want: []string{"internal-api-drive-stream.larksuite.com"}, + }, + { + name: "uppercase snake target", + source: "package p\nfunc f() { API_HOST := \"private.corp.internal\"; _ = API_HOST }\n", + want: []string{"private.corp.internal"}, + }, + { + name: "typed declaration", + source: "package p\nconst APIHost string = \"attacker.zip\"\n", + want: []string{"attacker.zip"}, + }, + { + name: "grouped const declaration", + source: "package p\nconst (\n APIHost string = \"attacker.zip\"\n)\n", + want: []string{"attacker.zip"}, + }, + { + name: "grouped var declaration", + source: "package p\nvar (\n APIHost string = \"attacker.zip\"\n)\n", + want: []string{"attacker.zip"}, + }, + { + name: "multi assignment", + source: "package p\nfunc f() {\n" + + " APIHost, BackupHost := \"public.example.com\", \"attacker.zip\"\n" + + " _, _ = APIHost, BackupHost\n}\n", + want: []string{"attacker.zip", "public.example.com"}, + }, + { + name: "map semantic key", + source: "package p\nvar c = map[string]string{\"host\": \"private.corp.internal\"}\n", + want: []string{"private.corp.internal"}, + }, + { + name: "map semantic key assignment", + source: "package p\nfunc f() { c := map[string]string{}; c[\"host\"] = \"private.corp.internal\" }\n", + want: []string{"private.corp.internal"}, + }, + { + name: "host collection values", + source: "package p\nvar ALLOWED_HOSTS = []string{\"private.corp.internal\", \"attacker.zip\"}\n", + want: []string{"attacker.zip", "private.corp.internal"}, + }, + { + name: "host collection map keys", + source: "package p\nvar allowedHosts = map[string]struct{}{\"attacker.zip\": {}}\n", + want: []string{"attacker.zip"}, + }, + { + name: "host collection bool map keys", + source: "package p\nvar AllowedHosts = map[string]bool{\"api.example.com\": true}\n", + want: []string{"api.example.com"}, + }, + { + name: "host collection map values", + source: "package p\nvar HostsByRegion = map[string]string{\"sg\": \"api.example.com\"}\n", + want: []string{"api.example.com"}, + }, + { + name: "host collection map value assignment", + source: "package p\nfunc f() {\n" + + " HostsByRegion := map[string]string{}\n" + + " HostsByRegion[\"sg\"] = \"api.example.com\"\n" + + "}\n", + want: []string{"api.example.com"}, + }, + { + name: "static concatenation", + source: "package p\nvar APIHost = \"attacker.\" + \"zip\"\n", + want: []string{"attacker.zip"}, + }, + { + name: "multiline assignment", + source: "package p\nfunc f() {\n APIHost :=\n \"attacker.zip\"\n _ = APIHost\n}\n", + want: []string{"attacker.zip"}, + }, + { + name: "escaped hostname", + source: "package p\nvar APIHost = \"private\\u002ecorp\\u002einternal\"\n", + want: []string{"private.corp.internal"}, + }, + { + name: "hex escaped hostname", + source: "package p\nvar APIHost = \"private\\x2ecorp\\x2einternal\"\n", + want: []string{"private.corp.internal"}, + }, + { + name: "octal escaped hostname", + source: "package p\nvar APIHost = \"private\\056corp\\056internal\"\n", + want: []string{"private.corp.internal"}, + }, + { + name: "raw hostname", + source: "package p\nvar APIHost = `private.corp.internal`\n", + want: []string{"private.corp.internal"}, + }, + { + name: "same-file constant reference", + source: "package p\nconst existingConst = \"private.corp.internal\"\n" + + "func f() { APIHost := existingConst; _ = APIHost }\n", + want: []string{"private.corp.internal"}, + }, + { + name: "absolute URL", + source: "package p\nvar message = \"https://private.corp.internal/v1\"\n", + want: []string{"private.corp.internal"}, + }, + { + name: "websocket URL with port", + source: "package p\nvar endpoint = \"wss://private.corp.internal:443/v1\"\n", + want: []string{"private.corp.internal"}, + }, + { + name: "URL userinfo query and fragment", + source: "package p\nvar endpoint = \" https://user:pass@private.corp.internal:8443/v1?q=1#result \"\n", + want: []string{"private.corp.internal"}, + }, + { + name: "IDN hostname", + source: "package p\nvar APIHost = \"例子.公司.cn\"\n", + want: []string{"例子.公司.cn"}, + }, + { + name: "case port and trailing dot normalization", + source: "package p\nvar APIHost = \"EXAMPLE.COM.:443\"\n", + want: []string{"example.com"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := evidenceHosts(scanDomainEvidence(t, tc.source)) + if len(got) != len(tc.want) { + t.Fatalf("hosts = %v, want %v", got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("hosts = %v, want %v", got, tc.want) + } + } + }) + } +} + +func TestGoDomainEvidenceTrueNegatives(t *testing.T) { + source := `package p + +import _ "github.com/larksuite/oapi-sdk-go/v3" + +var file = "archive.zip" +var event = "card.action.trigger" +var schema = "im.messages.list" +var configFile = "service.prod.json" +var version = "v1.2.3" +var email = "name@example.com" +var lowConfidence = "attacker.zip" +var downloadURL = "archive.zip/file" +var prose = "See https://private.corp.internal/v1 for details" +// https://private.corp.internal/v1 +var ghost = "private.corp.internal" +var hostnameParser = "private.corp.internal" +var domainError = "private.corp.internal" +var APIHost = "localhost" +var BackupHost = "127.0.0.1" +var hosts = struct{ File string }{File: "archive.zip"} +var AllowedHosts = map[string]string{"api.example.com": "client.pem"} + +func dynamicValue() string { return "private.corp.internal" } +var DynamicHost = dynamicValue() + +func setAmbiguousHostMetadata() { + AllowedHosts["api.example.com"] = "client.pem" +} +` + if got := scanDomainEvidence(t, source); len(got) != 0 { + t.Fatalf("unexpected evidence: %+v", got) + } +} + +func TestTypedStructFieldHostnameSemantics(t *testing.T) { + t.Run("network fields", func(t *testing.T) { + source := `package source + +type Config struct { Host string } +type FeishuSource struct { Domain string } + +var config = Config{Host: "api.example.com"} +var source = FeishuSource{Domain: "events.example.com"} +` + got := evidenceHosts(scanTypedDomainEvidenceInPackage( + t, + "github.com/larksuite/cli/internal/event/source", + source, + )) + want := []string{"api.example.com", "events.example.com"} + if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("hosts = %v, want %v", got, want) + } + }) + + t.Run("command metadata domain", func(t *testing.T) { + source := `package cmdmeta + +type Meta struct { Domain string } + +var meta = Meta{Domain: "im.messages"} +func update(meta *Meta) { meta.Domain = "docs.pages" } +` + if got := scanTypedDomainEvidenceInPackage( + t, + "github.com/larksuite/cli/internal/cmdmeta", + source, + ); len(got) != 0 { + t.Fatalf("unexpected command metadata evidence: %+v", got) + } + }) + + t.Run("card action host", func(t *testing.T) { + source := `package im + +type CardActionTriggerOutput struct { Host string } + +var output = CardActionTriggerOutput{Host: "card.action"} +func update(output *CardActionTriggerOutput) { output.Host = "im.message" } +` + if got := scanTypedDomainEvidenceInPackage( + t, + "github.com/larksuite/cli/events/im", + source, + ); len(got) != 0 { + t.Fatalf("unexpected card host evidence: %+v", got) + } + }) + + t.Run("unknown field ownership is conservative", func(t *testing.T) { + source := "package p\ntype Config struct { Host string }\nvar c = Config{Host: \"api.example.com\"}\n" + if got := scanDomainEvidence(t, source); len(got) != 0 { + t.Fatalf("unexpected untyped field evidence: %+v", got) + } + }) +} + +func TestHostnameSemanticNames(t *testing.T) { + for _, name := range []string{ + "host", "HOST", "hosts", "hostname", "domains", + "api_host", "API_HOST", "ALLOWED_HOSTS", + "apiHost", "APIHost", "backupHostname", + "HostsByRegion", "APIHostsByRegion", "hostsByRegion", + } { + if !isHostnameSemanticName(name) { + t.Errorf("%q should be hostname-semantic", name) + } + } + for _, name := range []string{ + "ghost", "hostnameParser", "domainError", "hostValue", "downloadURL", "endpoint", "origin", + "HostBypass", "APIHostBypass", + } { + if isHostnameSemanticName(name) { + t.Errorf("%q must not be hostname-semantic", name) + } + } +} + +func TestDomainFixturePaths(t *testing.T) { + for _, path := range []string{ + "internal/x/x_test.go", + "tests/cli_e2e/x.go", + "internal/x/testdata/sample.go", + } { + if !isDomainFixturePath(path) { + t.Errorf("%q should be fixture scope", path) + } + } + for _, path := range []string{ + "internal/x/test_helper.go", + "examples/demo.go", + "skills/example/testdata/sample.go", + "skills/example/example_test.go", + } { + if isDomainFixturePath(path) { + t.Errorf("%q must not be fixture scope", path) + } + } +} diff --git a/lint/main.go b/lint/main.go index 6d44b6c34..1ffb67bff 100644 --- a/lint/main.go +++ b/lint/main.go @@ -3,7 +3,7 @@ // Command lintcheck runs repository source-contract guards that golangci-lint // cannot express directly. It currently covers typed-error contracts and the -// resolver-owned endpoint contract. +// resolver-owned endpoint and approved-domain contracts. // // lintcheck lives in its own Go module under lint/ so its build-time // dependency on golang.org/x/tools/go/packages does not leak into the @@ -43,8 +43,10 @@ type scanner struct { var scanners = []scanner{ {name: "errscontract", fn: errscontract.ScanRepoWithOptions}, - {name: "domaincontract", fn: func(root string, _ errscontract.ScanOptions) ([]lintapi.Violation, error) { - return domaincontract.ScanRepo(root) + {name: "domaincontract", fn: func(root string, opts errscontract.ScanOptions) ([]lintapi.Violation, error) { + return domaincontract.ScanRepoWithOptions(root, domaincontract.ScanOptions{ + ChangedFrom: opts.ChangedFrom, + }) }}, } @@ -57,7 +59,7 @@ func main() { "Runs every registered lint domain against repo-root (default: current directory).\n") flag.PrintDefaults() } - flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental boundary-error checks") + flag.StringVar(&changedFrom, "changed-from", "", "base revision for incremental source-contract checks") flag.BoolVar(&printLegacyCommandErrorCandidates, "print-legacy-command-error-candidates", false, "print existing command boundary bare errors as allowlist candidates") flag.Parse()