Repository navigation
Conversation
RP1 took the first word after `docker run|create|pull` as the image and searched it for a tag. Options were reported as the image (`docker run --rm alpine:3.20` gave an unpinned image named `--rm`), and a registry port or an option value was read as a tag (`docker pull localhost:5000/team/tool`, `docker run --publish=8080:80 evil/image` gave no finding). Read the command as shell words and skip options using docker/cli's option table for each subcommand, so the first operand is the image. Check for a tag only in the image's last path component. When the image cannot be identified (unknown option, missing value, end of command, unterminated quote, or the 1024-character read bound), the command is still reported. test_privileged_payload_in_referenced_reference_file_stays_install_unsafe reached DO_NOT_INSTALL (56) only through the RP1 false positive on `docker run --privileged --pid=host vendor/collector:1.4`; with the pinned image no longer reported, PE5 and TM4 alone score 48. The fixture now uses an unpinned image so RP1 fires for a real reason. Signed-off-by: Ilai Goldschmidt <117302862+ilaigold@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yashrajp22
left a comment
There was a problem hiding this comment.
Two introduced RP1 missed-warning cases are confirmed below. The selected HEAD tests passed (173 passed, 4 expected failures) in source and fresh-wheel runs; separate focused checks reproduced both issues through the full CLI.
| Only the last path component is checked, because a registry port | ||
| (``localhost:5000/team/tool``) is not a tag. | ||
| """ | ||
| return _VERSION_PIN_RE.search(image.rsplit("/", 1)[-1]) is not None |
There was a problem hiding this comment.
Could we keep RP1 for image expressions whose selected value is unknown? For example, with IMAGE=alpine, docker run --rm "${IMAGE:-alpine:3.20}" selects unpinned alpine, but this check accepts the fallback’s :3.20 and suppresses the warning. "${IMAGE:1}" has the same problem when IMAGE=_alpine: :1 is a substring offset, not an image tag.
Both cases produced one RP1 finding on the baseline and none on this commit in direct analyzer checks and full CLI scans against source and freshly installed wheels. Please retain the conservative warning for unresolved expansions unless the resulting image is provably pinned.
| if expect_value: | ||
| expect_value = False | ||
| elif end_of_options or not word.startswith("-") or word == "-": | ||
| return word, pos |
There was a problem hiding this comment.
Could we handle shell redirections before accepting this word as the image, or conservatively leave the operand unresolved? In docker run --rm 2>log:1 alpine, the shell consumes 2>log:1 as stderr redirection and Docker runs the unpinned alpine image. This branch instead returns 2>log:1, and the pin check treats the filename’s :1 as an image tag.
The baseline emits RP1 for this input, while this commit emits no warning in both source and installed-wheel CLI scans. A redirection filename should not be able to suppress the real image’s warning.
The operand scan accepted two kinds of words that do not prove a pinned
image:
- An image built from an expansion. In "${IMAGE:-alpine:3.20}" the
fallback's :3.20 was read as the tag although IMAGE may hold an
unpinned image, and :1 in "${IMAGE:1}" is a substring offset. An image
word with an expansion now counts as pinned only when every expansion
is double-quoted and the literal text after the last one carries the
tag or digest: it contains a / ("${REGISTRY}/tool:1.4"), or it starts
with : or @ ("${IMAGE}:1.4"), which with no / after it can only begin
a tag or digest. Unquoted expansions, brace expansion, "$@" and
"${arr[@]}" are never resolved, because they can change which word is
the image, and neither are command substitutions, whose closing ) can
sit in a case pattern or a comment.
- A redirection. In `docker run --rm 2>log:1 alpine`, 2>log:1 was taken
as the image and :1 as its tag. Redirection operators now end a word
(alpine>log:1 is alpine), and an operator is skipped with its target
wherever it appears. A missing or unreadable target (2> at the end of
the line, <(env)) leaves the image unresolved, so the command is still
reported. A placeholder such as <image>:1.0 is read the same way and is
reported.
Signed-off-by: Ilai Goldschmidt <117302862+ilaigold@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the careful review and the repros. Both are fixed in b25acf8. Expansions: an image word with an expansion now only counts as pinned when every expansion is double-quoted and the tag or digest is literal text after the last one, either past a Redirections: Two things I left on purpose. A placeholder like |
yashrajp22
left a comment
There was a problem hiding this comment.
Rechecked b25acf8. The original expansion and redirection examples now behave as expected. Two distinct operand-selection cases still suppress RP1, detailed below.
Validation: 236 offline source/installed-wheel scans, with all 118 paired reports matching after removing timestamps and generated finding IDs. The selected HEAD tests passed 178 cases with 4 existing xfails in each mode. Forty scan reports retain partial coverage; this is scoped offline verification, not a claim of complete scanner accuracy.
| return None, pos | ||
| pos = word_match.end() | ||
| word = _SHELL_QUOTING_RE.sub(r"\1", word_match.group(0)) | ||
| if expect_value: |
There was a problem hiding this comment.
Could we keep the operand unresolved when an option value can expand into multiple arguments? With DOCKER_ENV='MODE=dev ubuntu', docker run --rm -e $DOCKER_ENV alpine:3.20 uses unpinned ubuntu as the image; alpine:3.20 becomes the container command. This branch skips one raw word and accepts that later tag instead. The baseline reports RP1, while this commit produces a complete scan with no findings in both source and wheel runs. Attached option values and quoted array expansions have the same problem. Please preserve the warning unless the option value is provably one argument.
| # A redirection operator with its optional file descriptor (2>, &>>, 2>&, <<<, {fd}>). | ||
| # The shell removes it and its target word wherever it appears in the command. | ||
| _SHELL_REDIRECTION_RE = re.compile( | ||
| r"(?:[0-9]+|\{[A-Za-z_][A-Za-z0-9_]*\})?(?:&>>?|<<<|<<-?|<>|<&|>&|>>|>\||[<>])" |
There was a problem hiding this comment.
Could we exclude &> and &>> from the optional descriptor prefix? Bash leaves 2 as an argument in docker run --rm 2&>log alpine:3.20: the image is untagged 2, and the later tagged word is the container command. Numeric image names are valid, including a local 2:latest image. This regex instead consumes 2&>log and accepts the later tag. The baseline reports RP1, while both source and wheel scans at this commit report no findings with complete coverage. The ordinary &>log alpine:3.20 control correctly stays clear.
Closes #781. It's the Docker image case from #672 that #683 left for later.
I hit this while scanning my skills with
--no-llm: on OpenShell'sdebug-openshell-clusterskill, RP1 said the unpinned image wasdocker run --rm. RP1 takes the first word afterdocker run|create|pullas the image and looks for a tag anywhere in it. So options get reported as images, and a registry port (localhost:5000/team/tool) or an option value (--publish=8080:80) passes as a tag.The fix reads the command as shell words, skips options, and only looks for a tag in the image's last path component. Two things you might ask about:
container/opts.go,run.go,create.go,image/pull.go), hidden and deprecated flags included, so nothing is guessed about which options take a value. A value option takes the next word even if it starts with-, same as pflag.$((...)), or the 1024-character read bound), the command is still reported, like onmain.Detections: a finding from
mainonly goes away when every word before the image is a known option (or a self-contained--name=value) and the image passes the same_VERSION_PIN_REcheckmainalready uses. On 20,000 generateddocker run|createcommands,mainmissed 1,026 unpinned images and this branch misses none. Pinned images that still get reported drop from 7,852 to 237, and every one of those has an unknown option.Tests: paired cases in
tests/test_mcp_rug_pull.py, 12 benign (pinned images after flags, value options,-p8080:80,--, line continuations) and 14 malicious with exactmatched_text(--user 1000:1000, a registry port,--publish=, the OpenShell${VAR:-...:latest}form). Another 9 check that commands with an unresolvable image are still reported.ReDoS: the alternatives in the shell-word regex each start with a different character class, and each scan reads at most 1024 characters past the subcommand.
test_rp1_docker_operand_scan_is_linearruns 7 adversarial ~50k-character lines with a 1 s limit each. Outside the suite,_check_rp1stayed under 0.1 s on 11 such lines, and the single-finding ones grew linearly up to 200k characters.One fixture change needs your call.
test_privileged_payload_in_referenced_reference_file_stays_install_unsafe(#682) only reached DO_NOT_INSTALL through the false positive (--privilegedreported as the image). Oncevendor/collector:1.4reads as pinned, PE5 and TM4 alone score 48 and the test fails. I made the fixture image unpinned so RP1 fires for a real reason, and left PE5/TM4 scoring alone since that's the #644 B7 question. Tell me if you'd rather handle it another way.matched_textnow runs fromdockerthrough the image, so fingerprints change for findings that used to name a flag (and wheremainkept a trailing backtick or;in the match).Checked on
main@ 5485cda:uv run pytest -m "not integration and not provider" tests/ -q: 10309 passed, 0 failedmcp_rug_pull.pyreverted tomainuv run make lintanduv run make format-checkpassdebug-openshell-cluster@ 360c5a0: same two RP1 findings, now naming the real images, score still 60🤖 Generated with Claude Code