fix(security): clear the two security:sast findings blocking pre-push (#729) - #730
fix(security): clear the two security:sast findings blocking pre-push (#729)#730scottschreckengaust wants to merge 4 commits into
Conversation
…#729) `mise run security:sast` was red on main, so the pre-push hook failed on every branch regardless of its diff — while SAST is not a required merge check, making the local gate stricter than the merge gate and training contributors to reach for --no-verify. Both findings are false positives, suppressed inline per the AGENTS.md convention rather than by widening config: - orchestration-comment-trigger.ts: `phrase` is never attacker-controlled — the sole caller passes RETRY_PHRASES literals and metachars are escaped one line above. `text` is the untrusted side and is the subject, not the pattern. - linear_epic.py: the target is the LINEAR_URL module constant, not a caller-supplied URL, so there is no SSRF surface. Verified the suppressions are line- and rule-scoped, not blanket: planting a genuinely user-controlled `new RegExp(userInput)` elsewhere in the same file is still reported. Co-Authored-By: Claude <noreply@anthropic.com>
52173e1 to
dfc4e26
Compare
theagenticguy
left a comment
There was a problem hiding this comment.
Verified both suppressions against the code at the PR head, independently of the PR description — both findings are genuine false positives and the suppressions are correctly scoped.
orchestration-comment-trigger.ts:221 — hasPhrase is module-private with exactly one call site (parseRetryIntent, line 250), which passes only literals from the RETRY_PHRASES const as the pattern. The untrusted comment text flows into the text parameter, which is the regex subject, not the pattern. Metacharacters in the phrase are escaped on the preceding line, and the resulting pattern (^|[^a-z0-9])<literal>([^a-z0-9]|$) has no nested quantifiers, so there's no ReDoS surface either. If a future caller ever feeds hasPhrase a dynamic phrase this reasoning breaks, but the suppression comment states the invariant plainly enough that a reviewer of that future change would see it sitting right there.
linear_epic.py:89 — the Request is constructed against the LINEAR_URL module constant (https://api.linear.app/graphql); nothing caller- or environment-supplied reaches the URL, so no SSRF and no scheme-confusion (file://) surface.
Points in the PR's favor beyond the suppressions themselves:
- Rule-scoped, line-scoped, with inline reasons — per the AGENTS.md
nosemgrep: <rule-id> -- <reason>convention, rather than widening semgrep config or path-excluding files. - The negative test (planting a genuinely user-controlled
new RegExp(userInput)in the same file and confirming semgrep still fires) is exactly the right way to prove a suppression is narrow. Most suppression PRs skip this. - The
--no-verifybypass is documented with per-gate verification rather than hand-waved, and the mutual-blocking situation with #728 is now resolved on main.
One non-blocking observation: AGENTS.md documents the nosemgrep convention under security:sast:masking specifically. This PR reasonably extends the same convention to security:sast; a one-line AGENTS.md touch-up generalizing that sentence would keep the docs honest, but it doesn't need to happen in this PR.
Approving now so it's unblocked once you rebase and mark ready — as noted in the description, confirm the pre-push hook passes clean without --no-verify on the rebased branch before undrafting.
…essing them (#729) The prior commit on this branch cleared the two `security:sast` findings with inline `nosemgrep` lines. Both are now fixed at the source and the suppressions are gone — `security:sast` passes with zero suppressions in either file. **`detect-non-literal-regexp` (orchestration-comment-trigger.ts)** `hasPhrase` built a regex by interpolating `phrase`, which forced a metacharacter-escaping step to stay *correct* (not merely quiet) and made the phrase list a regex-injection/ReDoS surface the moment it grew a non-literal member. Replaced with a plain `indexOf` scan that checks the flanking chars, so the input cannot be misparsed as a pattern and the escaping step disappears. Proven behaviour-preserving by differential test against the old regex over 2,097,152 (text, phrase) pairs — including case variants, Unicode, and every regex metacharacter: 0 mismatches. Two characterization tests now pin the boundary and match-literally contracts (they pass on both implementations). **`dynamic-urllib-use-detected` (scripts/linear_epic.py)** The real issue was not the URL — it was the opener. `urlopen` uses the default handler set, which includes `file://`, `ftp://` and `data://`, in a script that carries the Linear PAT. That was a live capability, not a phantom: the old path demonstrably reads a local file. Now built via a bare `OpenerDirector` with only `HTTPSHandler` (+ the error handlers, so `except HTTPError` still fires), making HTTPS the only reachable scheme. Note `build_opener(HTTPSHandler)` would have satisfied the scanner while still carrying `FileHandler`/`FTPHandler`/`DataHandler` — a dodge, not a fix. Verified: `security:sast` exit 0 · both findings still detected when a genuine `new RegExp(userInput)` / `urlopen(user_url)` is planted, so the rules remain live · cdk 176/176 suites (3515 tests) · cli 55/55 (695) · eslint clean, no mutations · https 200 + HTTPError paths exercised against a real endpoint · ruff findings on the touched file drop 15 → 13, none new. Also merges current `main` so the stale `mise.toml` on this branch no longer reverts #728's gitleaks scoping. Refs #729, #695, #540/#722, #728
theagenticguy
left a comment
There was a problem hiding this comment.
Approve. Both findings are now fixed at the source with zero suppressions remaining, and we re-verified every load-bearing claim in the description independently on the head commit (65915b9) rather than taking the test plan's word:
TypeScript — hasPhrase string scan. Differential-tested the shipped implementation against the old regex version: 400,040 (text, phrase) pairs across all 8 RETRY_PHRASES — fuzzed strings over a metachar/Unicode/whitespace alphabet plus explicit uppercase-boundary probes (ARETRY, xRETRYx, a RETRY b) — 0 mismatches. The self-lowercasing makes the function safe even for a future caller that skips normalization, which is stricter than the contract the old caller relied on. The two new characterization tests pin exactly the behaviors that used to live implicitly in the regex (word-boundary rejection, literal-not-pattern matching). 29/29 tests pass; tsc --noEmit clean.
Python — hand-built OpenerDirector. Reproduced both sides of the capability claim: the default opener reads file:///tmp/secret.txt back verbatim; the restricted opener returns no content for the same URL. Live-checked the HTTPS path against api.linear.app/graphql: an unauthenticated POST raises HTTPError (400) through HTTPErrorProcessor, so gql()'s existing error handling is intact. The description's note that build_opener(HTTPSHandler) would have quieted the scanner while still carrying FileHandler is correct and worth having on the record — the hand-built director is the difference between silencing the rule and removing the capability.
Scanner state. Both original rules plus httpsconnection-detected exit 0 on the touched files; the repo's full pack set (p/python, p/typescript, p/owasp-top-ten, p/security-audit) reports zero findings on them. grep nosemgrep on both files: empty.
One non-blocking observation, no change requested: the restricted opener has no UnknownHandler, so a non-HTTPS URL yields open() → None and the with statement dies on AttributeError rather than a clean error message. That path is only reachable by editing the LINEAR_URL constant, and failing closed with a crash beats failing open — just noting the shape of the failure for whoever hits it.
All visible required checks pass (CodeQL all three languages, secrets/deps scan, dead-code advisory); the build (agentcore) job was still pending at review time.
Summary
Clears the two
security:sastfindings that were red onmain, so the pre-push hook stops failing on every branch regardless of its diff.Both findings are now fixed at the source. The earlier revision of this branch suppressed them with justified inline
nosemgreplines; that was the wrong call, and those suppressions are gone. Zero suppressions remain in either file.security:sastis not a required merge check (security-pr.ymldefers the heavy SAST suite tosecurity.yml), so the local gate was stricter than the merge gate — the same asymmetry class as #721/#728, and the kind that trains contributors to reach for--no-verify.1.
detect-non-literal-regexp—orchestration-comment-trigger.tshasPhrasebuilt its matcher by interpolatingphraseintonew RegExp. Even though today's callers only pass literals fromRETRY_PHRASES, the dynamic construction is what forced the metacharacter-escaping step to exist in order to stay correct (not merely quiet):re-runmust match literally, and one non-literal phrase added later would turn the phrase list into a regex-injection / ReDoS surface.Replaced with a plain
indexOfscan that checks the flanking characters directly. The input can no longer be misparsed as a pattern, and the escaping step disappears along with the finding.Proven behaviour-preserving, not assumed: differential-tested against the old regex over 2,097,152
(text, phrase)pairs — case variants, Unicode, whitespace runs, and every regex metacharacter — with 0 mismatches. Two characterization tests now pin the contracts that were previously implicit (both pass against the old and new implementations):retryx,abcretry,9retry) must not firere-run✓ butre.run/reXrun/.*/(retr)y✗)2.
dynamic-urllib-use-detected—scripts/linear_epic.pyThe URL was never the real issue — the opener was.
urlopenuses the default handler set, which includesfile://,ftp://anddata://, in a script that carries the Linear PAT. That was a live capability rather than a theoretical one; the old code path demonstrably reads a local file:The request now goes through a bare
OpenerDirectorcarrying onlyHTTPSHandler(plusHTTPErrorProcessor/HTTPDefaultErrorHandler, so the existingexcept urllib.error.HTTPErrorstill fires). HTTPS is the only reachable scheme:Worth recording for future reviewers:
build_opener(HTTPSHandler)would have satisfied the scanner while still carryingFileHandler/FTPHandler/DataHandler— a dodge that reads like a fix. Constructing the director by hand is what actually removes the capability.Test plan
mise run security:sast→ exit 0, with nonosemgrepin either filenew RegExp(userInput)andurlopen(user_url); semgrep reported both. Reverted after, verified absent.file://read returns nothing under the new opener; the default opener returns file contents. Real-endpoint check: HTTPS 200 succeeds and a 404 still raisesHTTPError, sogql()'s error handling is intact.orchestration-comment-trigger.test.ts→ 29/29 (27 existing + 2 new contract tests)//cdk:eslint --fixclean, no mutations ·linear_epic.pyparses,--helpworksrufffindings on the touched file drop 15 → 13 (fixedI001, removed oneS310); none new. The remainingS310is onRequest(...)construction and is pre-existing onmain.origin/main..HEAD)Pushed with
--no-verifyThe sole pre-push blocker is
security:sast:masking, which fails identically on cleanmain(28silent-success-maskingfindings, none in either file this PR touches) — pre-existing debt, not a regression here, and remediating ~20 unrelated files belongs in its own issue (#542 tracks that gate). Every other gate was verified individually first, as listed above.Related: #729 (this issue), #728/#721/#723 (the gitleaks half), #540/#722 (semgrep pinning —
--config autoresolves rules from the registry at scan time, so new findings can appear without a code change), #542 (security:sast:masking), #695 (introduced the flagged lines).🤖 Generated with Claude Code