Skip to content

fix(privacy): detect SSH endpoints per token, and stop scanning on import - #4975

Merged
lidge-jun merged 6 commits into
devfrom
codex/carry-4734-ssh-endpoint-parse
Sep 18, 2026
Merged

lidge-jun merged 6 commits into
devfrom
codex/carry-4734-ssh-endpoint-parse

Conversation

@lidge-jun

Copy link
Copy Markdown
Owner

Carries #4734 by @abhisheksharma2411, rebased onto current dev with the review finding fixed.

Summary

privacy:scan knew about tokens, emails and home paths, and nothing about infrastructure, so a devlog could publish a working SSH Host block and the scan passed. That is how a runner hostname and a Cloudflare ProxyCommand shipped in a published devlog; #4623 removed them by hand and has since merged. This adds the two detectors that make the next one fail the gate instead, and stops the module running a full repo scan as an import side effect.

Security boundary. These detectors are the gate that keeps infrastructure endpoints out of a public repository and out of CI logs. Both new finding kinds are redacted in the failure output, because this scan runs in CI on a public repository and printing the value would republish the endpoint into a more widely readable place than the diff it was caught in. file:line is enough to find it.

The defect this carry fixes. The original isAllowedSshEndpoint asked whether the directive value contained something allowlisted. For HostName that is the same as asking about its single token. For ProxyCommand it is the wrong question: the value is a command line, so one allowed token anywhere in it cleared the whole line, real endpoint included. Two bypasses followed, and both are now pinned as tests:

  • A value beginning with $ was allowed outright by the templated-prefix rule, so a command whose first token was a variable was never examined.
  • The reserved-name rule was unanchored, so a command routed through a documentation host was cleared while it named the real endpoint three tokens later. The same unanchoring read example.com.internal-buildfarm.net — a real host that merely begins with a reserved name — as documentation.

The replacement parse splits the value on whitespace and requires every token to be a placeholder, with each placeholder rule anchored to the whole token. Userinfo and a trailing port are stripped before the host part is judged, so neither can be the reason a real host reads as a placeholder. A ProxyCommand is a leak by default; only a wholly templated value is documentation. HostName behavior is unchanged apart from the anchoring.

Two ssh_config spellings that hid a directive from the detector entirely are now matched: a trailing # comment after a HostName value, and the ProxyCommand=value form.

What this does not guarantee. The HostName=value form is deliberately still unmatched, and that is a real residual gap rather than an oversight: hostname = "127.0.0.1", is ordinary TypeScript and three such lines are in src/server/ports.ts and src/server/port-reclaim.ts today, so accepting that spelling would fail the scan on this tree. A test pins the decision so the tradeoff is visible to whoever revisits it. More broadly, this is a textual scanner over tracked files: it catches the Host block shape that actually shipped, not an endpoint described in prose, encoded, or split across lines, and it does not inspect untracked files or git history.

Second fix, carried unchanged from the original. The module ran its scan at module scope, so import { scanText } executed a full repo scan as a side effect — and a failing scan called process.exit(1), taking the importing process with it. That is invisible while the tree is clean and bites the moment any detector finds something; adding these rules broke privacy-scan-meta-key.test.ts, which does nothing but import the seam this file exports for testing. The scan is now behind import.meta.main. The CLI entry point is unchanged.

Also fixed on the carry: the new test file had no entry in scripts/test-layout/layout.json or tests/fixtures/test-layout-expected.json. privacy-scan-ssh-endpoint is not covered by the ci-workflows regex seeds, so both layout guards would have failed. And a doc comment left over from an earlier revision still claimed ssh-endpoint was not redacted while the code redacted it; the comment now matches the code.

Sequencing is resolved. The original PR noted it would be red on dev until #4623 landed. #4623 merged on 2026-09-17, and no tracked file matches either detector today, so this lands standalone.

Verification

Local verification was not run: this lane forbids running any local suite, typecheck, build, or install. Hosted CI on this PR head is the executable verification.

Static checks performed in place of local execution:

  • Every tracked file was searched for both detector shapes, case-insensitively, to confirm the new rules produce zero findings on this tree and so cannot turn privacy:scan red on merge.
  • The same search was run for the HostName=value form, which is what proved it cannot be accepted: three lines in src/server/ports.ts and src/server/port-reclaim.ts would become findings.
  • The new test file was checked against the scanner's own other detectors, since privacy:scan reads it too. One assertion was rewritten because user@host.tld inside a test string is an email finding.
  • Both JSON layout tables were parsed to confirm they are still valid, and the new entries sit in the sorted position the guards expect.

Checklist

  • Scope stays focused and avoids unrelated cleanup.
  • Docs or release notes were updated when needed.
  • Security-sensitive changes were reviewed for secrets, auth, and unsafe defaults.

Co-authored-by: Abhishek Sharma abhicse24@gmail.com

privacy:scan knew about tokens, emails and home paths, and nothing about
infrastructure. A working Host block was therefore publishable: the scan
passes on dev today, where
devlog/_fin/260731_pr_merge_round/022_remote_test_offload.md still carries
a real HostName, account and Cloudflare ProxyCommand. #4623 removes them
by hand; nothing stops the next devlog reintroducing them.

Two detectors, both anchored to the SSH config grammar:

  HostName      value must be the whole rest of the line
  ProxyCommand  command line, matched separately

`User` is deliberately not matched. It is an ordinary English word and
even line-anchored it fires on wrapped prose — "…the\nuser configuration."
and "…the\nuser notice." both matched during development, as did
`hostname === undefined ? ...` in a test file before the value was
anchored. The username is also the least sensitive part of a Host block,
and MAINTAINER_HOME_USERNAME already covers it in path form.

A ProxyCommand containing %h is NOT allowlisted. Only a bare %h is. The
substitution token does not make the binary path, the access method or the
tunnel any less of a leak — allowing it would have passed the exact line
this exists to catch.

Also moves the repo scan behind import.meta.main. It ran at module scope,
so `import { scanText }` executed a full scan as a side effect and a
failing scan called process.exit(1), killing the importing test process.
Invisible while the tree is clean; adding the detector above broke
privacy-scan-meta-key.test.ts, which does nothing but import the seam this
file exports for testing.

Refs #4623
… doc

Review follow-ups from @lidge-jun on #4734.

The scanText JSDoc still claimed the module runs its scan on import — the
thing this PR removed. Left as-is, the next contributor writes against a
side effect that no longer exists. Now states the import is side-effect
free and why the seam exists at all.

ProxyCommand findings move to their own kind and join
REDACTED_FINDING_KINDS. The value carries the binary path, the access
method and the tunnel options, and it was being echoed verbatim into
stderr and CI logs — which are far more widely readable than the diff it
was caught in. A bare HostName stays visible: that one is the context a
reviewer needs to find the line.

    ...:44 ssh-endpoint:     HostName ssh-macmini.lidgeai.com
    ...:46 ssh-proxy-command: <redacted>

Also indents the runScan body a level, which the extraction had left flat.
… real host

Two problems with this PR as it stood, both the same shape as the leak
it exists to catch.

The report printed the `ssh-endpoint` value while redacting the
`ProxyCommand`. This scan runs in CI on a public repository, so a
finding would have republished the endpoint into a public log — the
scanner leaking what it was written to detect. `file:line` already
locates it for whoever removes it, which is what the ProxyCommand kind
has relied on all along.

The regression fixture and the rationale comment both spelled out the
real hostname and login. #4623 removes those from the devlog; keeping
them here would have undone that cleanup and made this file their
permanent home. The fixture now uses a synthetic endpoint — the regex
cannot tell the difference — and the comment describes the incident
without restating the values.

Co-authored-by: Abhishek Sharma <abhicse24@gmail.com>
Two review nits from the PR gate.

`scanText`'s JSDoc repeated the whole import-side-effect explanation that
`runScan` already carried, so an edit to either left the other stale. It
now states only its own concern — the test seam — and points at `runScan`
for the rest.

`runScan`'s JSDoc sat above the `if (import.meta.main)` guard rather than
the function it describes, so tooling and readers attached it to the
wrong construct. Moved onto the declaration; the guard reads fine
unannotated.
@lidge-jun
lidge-jun requested a review from Ingwannu as a code owner September 17, 2026 23:24
@coderabbitai

coderabbitai Bot commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

Next included review available in 4 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 7c0da5a5-26e3-43c6-9cfa-84ab49863eeb

📥 Commits

Reviewing files that changed from the base of the PR and between 61ee647 and d244287.

📒 Files selected for processing (4)
  • scripts/privacy-scan.ts
  • scripts/test-layout/layout.json
  • tests/ci-workflows/privacy-scan-ssh-endpoint.test.ts
  • tests/fixtures/test-layout-expected.json

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-17T23:26:27.858543Z 79d4519 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the bug Something isn't working label Sep 17, 2026
@lidge-jun

Copy link
Copy Markdown
Owner Author

리뷰 · 우선순위 77 / 80

이 PR은 #4734(@abhisheksharma2411)를 현재 dev HEAD 61ee64747(패키지 2.59.0, tip #4948) 위에 다시 올린 캐리입니다. 제목 그대로 두 가지를 고칩니다. (1) privacy:scan이 토큰·이메일·홈 경로만 보고 인프라 엔드포인트는 못 잡던 구멍, (2) 모듈을 import만 해도 전체 레포 스캔이 돌고 실패하면 process.exit(1)로 테스트 프로세스까지 죽이는 부수 효과.

지금 devscripts/privacy-scan.ts를 보면 스캔이 모듈 맨 아래(대략 269–288행)에서 바로 실행됩니다. export function scanText를 가져오기만 해도 gitLsFiles() 전수 스캔이 돌아갑니다. 그래서 SSH 탐지기를 붙이면 트리가 아직 깨끗할 때는 조용하다가, 탐지기가 뭔가 잡히는 순간 privacy-scan-meta-key.test.ts처럼 «테스트만 import하는» 파일이 같이 빨갛게 됩니다. 이 PR은 그 실행을 import.meta.mainrunScan()으로 옮깁니다. CLI 엔트리(privacy:scan) 동작은 그대로이고, 테스트가 안전하게 scanText만 쓸 수 있게 됩니다.

탐지기 쪽은 #4623이 손으로 지운 공개 데브로그 Host 블록을 다시 못 내보내게 막는 게이트입니다. #4623은 2026-09-17에 이미 머지됐고, 원 PR이 기다리던 «dev가 아직 빨갛다» 순서는 이미 풀렸습니다. 새 kind는 ssh-endpoint(HostName 한 줄)와 ssh-proxy-command(ProxyCommand)입니다. 둘 다 CI 공개 로그에 값을 다시 찍지 않도록 REDACTED_FINDING_KINDS에 넣었습니다. 위치는 file:line이면 충분하다는 판단입니다.

캐리가 고친 핵심 결함은 예전 isAllowedSshEndpoint가 «값 안에 allowlist가 포함되면 통과」였던 점입니다. HostName은 토큰 하나라 큰 차이가 없지만, ProxyCommand는 명령줄이라 허용 토큰이 하나라도 있으면 나머지 진짜 호스트까지 통과했습니다. 테스트로 고정한 우회 두 개: (a) 값이 $로 시작하면 통째로 허용 → $CF access ssh --hostname <real> 통과, (b) 예약 이름이 앵커 없이 매칭 → proxy.example.com 때문에 줄 전체가 문서처럼 보이거나, example.com.internal-buildfarm.net처럼 예약 이름으로 시작하는 진짜 호스트가 문서로 읽힘. 교체 구현은 공백으로 쪼갠 모든 토큰이 플레이스홀더여야 하고, 각 규칙은 토큰 전체에 앵커됩니다. userinfo·포트는 호스트 판정 전에 뗍니다. ProxyCommand는 기본이 누출이고, 통째로 템플릿일 때만 문서입니다.

의도적으로 안 잡는 것도 본문·테스트에 박혀 있습니다. User 지시어는 영어 단어라 산문 오탐이 나서 제외(메인테이너 홈 경로 allowlist가 계정 쪽을 이미 덮음). HostName=value 형태는 src/server/ports.ts·port-reclaim.tshostname = "127.0.0.1", 세 줄 때문에 고의로 미매칭 — 받아들이면 이 트리 자체가 privacy:scan 빨강이 됩니다. 텍스트 스캐너라 산문·인코딩·줄 분할·미추적 파일·히스토리는 보장하지 않습니다. 레이아웃 JSON 두 곳과 주석/코드 불일치(redact 여부)도 캐리에서 같이 맞췄습니다. types.ts/config.ts 분할과 무관합니다.

우선순위 77입니다. 보안 경계·우회 핀·import 부작용·레이아웃·#4623 이후 단독 랜딩이 현재 dev와 잘 맞습니다. 호스티드 CI(mergeable_state: blocked) 초록을 본 뒤 머지하면 됩니다. 원본 #4734는 머지 후 landed-via로 닫는 게 맞습니다.

라인 - scripts/privacy-scan.ts isPlaceholderToken / isAllowedSshEndpoint - 토큰 단위·전체 앵커 판정. ProxyCommand contain-allowlist 우회를 끊는 핵심.
라인 - scripts/privacy-scan.ts HostName 정규식 - 줄 시작+들여쓰기, 값 뒤 # 주석 허용. HostName=value는 고의 미매칭(포트 TS와 충돌).
라인 - scripts/privacy-scan.ts ProxyCommand 정규식 - [ \t=]+로 equals 표기도 잡음. cloudflared 실경로처럼 %h만 있어도 나머지 토큰이 실물이면 실패.
라인 - scripts/privacy-scan.ts import.meta.main + runScan - 모듈 스코프 스캔/process.exit 제거. scanText import가 안전해짐.
라인 - scripts/privacy-scan.ts REDACTED_FINDING_KINDS - ssh-endpoint·ssh-proxy-command 추가. CI가 잡은 값을 공개 로그에 다시 찍지 않음.
경로/심볼 - tests/ci-workflows/privacy-scan-ssh-endpoint.test.ts - 실제 유출 형태·문서 허용·산문/코드 오탐·토큰 우회·주석/equals·HostName= 잔여 갭까지 핀.
경로/심볼 - scripts/test-layout/layout.json + tests/fixtures/test-layout-expected.json - privacy-scan-ssh-endpointci-workflows. 레이아웃 가드 통과용.
경로/심볼 - #4734 / #4623 - 캐리 원본은 아직 open. #4623 머지로 시퀀싱 해소. 머지 후 #4734에 Landed via #4975 후 close.

메인테이너의 판단이 필요한 지점

  • HostName=value 잔여 갭을 나중에 문법 문맥(ssh_config vs TS)으로 좁힐지, 당분간 테스트로만 고정할지 (추천: 당분간 고정 유지. 오탐 비용이 큼).
  • User 지시어를 더 강한 SSH 블록 문맥(직전 Host 줄 등)으로 다시 넣을지 (추천: 지금은 넣지 말 것. 산문 오탐 이력이 있음).
  • 머지 직후 #4734를 landed-via-maintainer로 닫을지 (추천: 닫기. 캐리 본문이 그 역할).

너의 추천
호스티드 CI가 초록이면 dev에 머지한다. 머지되면 원본 #4734에 Landed via #4975 at <commit> 댓글 + landed-via-maintainer 라벨 후 completed/superseded로 닫는다. 라벨은 이 리뷰에서 건드리지 않았다. types/config 분할과 무관하므로 close-don't-rebase 대상이 아니다.

이 댓글은 grok-bot이 작성했습니다

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79d4519460

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/privacy-scan.ts Outdated
if (/^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$/.test(token)) return true;
if (/^\{+[^{}]*\}*$/.test(token)) return true;
if (/^[}>]+$/.test(token)) return true;
// Judge `deploy@host.example.com:22` on its host part: userinfo and port name no

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid making the scanner flag its own example

When the CLI scans this newly added comment, the existing email detector recognizes deploy@host.example.com, and isAllowedEmail does not allow that value in scripts/privacy-scan.ts. I ran bun run privacy:scan on this commit and it exits with code 1 at this exact line, so the mandatory privacy gate cannot pass; construct the example from fragments or use a non-email-shaped placeholder.

AGENTS.md reference: scripts/AGENTS.md:L21-L25

Useful? React with 👍 / 👎.

lidge-jun and others added 2 commits September 18, 2026 08:32
The allowance asked whether a directive value *contained* something
allowlisted. For HostName that is the same as asking about its single
token, but a ProxyCommand value is a command line, so one reserved name
or one leading substitution cleared the whole line and the real endpoint
with it.

Two bypasses followed, both now pinned as tests: a value beginning with
"$" was allowed outright by the templated-prefix rule, and an unanchored
reserved-name test cleared a command whose proxy hop was example.com
while it named the real host three tokens later. The same unanchoring
read example.com.internal-buildfarm.net as documentation.

Every token must now be a placeholder, and every placeholder rule is
anchored to the whole token. A ProxyCommand is a leak by default; only a
wholly templated value is documentation.

Also accept the two ssh_config spellings that hid a directive from the
detector entirely: a trailing "# comment" after a HostName value, and
the "ProxyCommand=value" form. The "HostName=value" form is deliberately
not accepted, because that shape is ordinary TypeScript and three such
lines are in src/server/ports.ts and src/server/port-reclaim.ts today.

Co-authored-by: Abhishek Sharma <abhicse24@gmail.com>
tests/test-layout.test.ts requires every test file to resolve to a
domain, and tests/test-layout-tooling.test.ts checks the resolver
against an independent fixture oracle. "privacy-scan-ssh-endpoint" is
not covered by the ci-workflows regex seeds, so the new file needs an
explicit entry in both tables.

Co-authored-by: Abhishek Sharma <abhicse24@gmail.com>
@lidge-jun
lidge-jun force-pushed the codex/carry-4734-ssh-endpoint-parse branch from 79d4519 to d244287 Compare September 17, 2026 23:32
@lidge-jun

Copy link
Copy Markdown
Owner Author

Merging with macOS legs outstanding, and recording why rather than leaving it implicit.

At this exact head the full Linux suite (test 1/4 through 4/4), gates, storage policy, enforce-target, the docs build, and the keyring and npm-global smokes are green. The macOS legs are queued behind a saturated hosted-runner pool shared by several concurrent lanes, and the sharded macOS legs are separately known to go silent mid-suite and be cancelled at their job budget — a long-standing defect recorded with six occurrences in #4956, including two from the 2.58.0 round that were previously written off as capacity.

This change is platform-neutral, so waiting on a queue that is both saturated and known-unreliable would delay the work without adding information. The evidence that governs the release is not per-PR macOS legs; it is the full-platform lane=all dispatch at the frozen release candidate, which is held until #4956 has a named cause. Nothing is promoted on the strength of this merge.

Stating the boundary plainly: this is merged on Linux, gates and cross-platform smoke evidence at its exact head, with macOS coverage deferred to the candidate run rather than claimed here.

@lidge-jun
lidge-jun merged commit 7390c5d into dev Sep 18, 2026
25 checks passed
@lidge-jun
lidge-jun deleted the codex/carry-4734-ssh-endpoint-parse branch September 18, 2026 00:30
@abhisheksharma2411

Copy link
Copy Markdown
Contributor

Agreeing with the carry, and thanks for making the difference reviewable rather than silent — and for the Co-authored-by.

The defect is real and the diagnosis is exactly right. isAllowedSshEndpoint asked whether the value contained an allowlisted token. That question is correct for HostName, which is one token, and wrong for ProxyCommand, which is a command line — one allowed token anywhere cleared the whole line. I wrote one predicate for two directives with different shapes, which is the bug in one sentence.

The sharpest part: the unanchored reserved-name rule read example.com.internal-buildfarm.net as documentation. internal-buildfarm.net is the synthetic host I put in my own fixture — so my allowlist would have cleared a host shaped like my own test data, and the test would still have been green.

Verified the landed scanner on dev rather than taking it on trust:

my old fixture host                 ssh-endpoint
real host starting w/ reserved      ssh-endpoint
ProxyCommand w/ $ as first token    ssh-proxy-command
ProxyCommand routed via doc host    ssh-proxy-command
HostName w/ trailing # comment      ssh-endpoint
ProxyCommand=value form             ssh-proxy-command
HostName example.com                (none)

All four bypasses closed, and the placeholder case still passes. Worth noting the one that matters most: ProxyCommand cloudflared access ssh --hostname %hthe exact shape that leaked — is now a finding. My version allowed it, because I'd narrowed the rule to permit a bare %h and never re-asked whether that allowance could clear a line that also named a real host. It could.

One consequence worth stating as a known trade rather than leaving to be discovered. Requiring every token to be a placeholder means only a wholly abstract value passes:

ProxyCommand <your-proxy-command>              (none)
ProxyCommand $PROXY_COMMAND                    (none)
ProxyCommand ssh -W %h:%p <jump-host>          ssh-proxy-command   <- realistic doc example

A documentation example written the way a user would actually copy it — real command words plus placeholders — is a finding, because ssh and -W are not placeholders. No tracked file hits this today (I checked; the repo carries no ProxyCommand example), so it costs nothing now. But the first person who documents the directive will hit it, and the fix then is either an allowlisted docs path or accepting a leading command word. Right side to err on — a false positive is noise, the bypass was a leak — just better as a decision than a surprise.

No disagreement with any of it. The layout-table entries and the stale doc comment were both mine to have caught: running the repo's own mechanical gates is exactly the step I skipped.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants