Summary
PR #2669 (commit c2c9aae) encoded \r as %0D for the --format=gh-annotations output path to fix GitHub Actions workflow-command injection. The fix is incomplete: three other code paths still emit raw \r bytes to stdout when a scanned source path contains \r.
Because the GitHub Actions runner treats \r as a line terminator while parsing ::command::value sequences from stdout, an attacker who can introduce a directory name containing \r::stop-commands::TOKEN\r into a CI run can:
- Inject arbitrary workflow commands (
::error::, ::warning::, ::add-mask::, ::stop-commands::).
- Silently disable all subsequent command parsing via
::stop-commands::, causing every later ::error::CVE-XXXX annotation that osv-scanner itself emits to be dropped — the PR status check turns green and the maintainer merges believing the dependency change is clean.
This effectively defeats osv-scanner's value as a CI gate while the tool appears to run normally.
Severity note: this primitive silently bypasses osv-scanner's CI gate — vulnerable-dependency PRs can be merged with a green check. The trigger does not require maintainer approval — Sink 1 fires on the scan invocation itself, so a first-time external PR contributor can exploit this on any public repository using osv-scanner-action as a required check.
Confirmed against v2.3.5 (b4e672e, post-patch) by od -c of stdout raw \r bytes are present.
Coordinated disclosure via Google OSS VRP; triage requested public upstream coordination, hence this issue.
Affected sinks (all post-c2c9aae)
All three paths interpolate a user-controlled source/directory path into a string that is written to stdout without \r / \n encoding.
Sink 1 — pkg/osvscanner/scan.go:153
cmdlogger.Infof("Scanning dir %s", path) // `path` interpolated verbatim
This is emitted on every scan, before any output format is selected — so it fires regardless of whether the user passes --format=gh-annotations or not. Info level routes to stdout via internal/cmdlogger/handler.go:79:
_, err := fmt.Fprint(c.writer(record.Level), record.Message+"\n")
// ^ no \r encoding
Sink 2 — internal/output/vertical.go:158
text.FgMagenta.Sprintf("%s", result.Name) // source path printed verbatim
Sink 3 — internal/output/table.go:143
outputTable.SetTitle("Source:" + source.Name) // source path embedded raw
The patched gh-annotations path correctly encodes \r → %0D. None of the three sinks above do.
Why GitHub Actions is vulnerable to \r
The GHA runner treats both \n and \r as line terminators when scanning a step's stdout for ::command::value sequences. A single line containing:
dir\r::stop-commands::TOK\r::error::INJECTED
is parsed by the runner as three lines, including a live ::stop-commands::TOK directive. After that directive, all subsequent workflow commands are ignored until the matching ::TOK:: is emitted — which the attacker can simply choose not to.
This is the same class of bug that PR #2669 fixed for gh-annotations; it just wasn't fixed everywhere.
Proof (byte-level)
od -c of osv-scanner stdout when scanning a directory whose name contains \r:
d i r \r : : s t o p - c o m m a n d s : : T \r : : e r r o r : :
Raw \r bytes in stdout, confirmed against v2.3.5 (b4e672e).
Reproducer
Tested with bash 5.x. If using zsh/dash, replace with printf '%b' or use $'...' ANSI-C quoting.
# 1. Build
git clone --depth 50 https://github.com/google/osv-scanner.git /tmp/osv-src
cd /tmp/osv-src && go build -o /tmp/osv-bin ./cmd/osv-scanner/
# 2. Create a directory whose name embeds workflow commands via \r
DIR="$(printf '/tmp/poc/dir\r::stop-commands::T\r::error::INJECTED\r::warning::pwn')"
mkdir -p "$DIR"
cat > "$DIR/package-lock.json" <<'JSON'
{
"name": "x", "version": "1.0.0", "lockfileVersion": 3, "requires": true,
"packages": {
"": { "name": "x", "version": "1.0.0", "dependencies": { "lodash": "4.17.20" } },
"node_modules/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==",
"license": "MIT"
}
}
}
JSON
# 3. Run scanner; map \r to \n so we can grep the injected commands
/tmp/osv-bin --offline-vulnerabilities --download-offline-databases \
"$DIR/package-lock.json" 2>/dev/null | tr '\r' '\n' | grep '^::'
Output:
::stop-commands::T
::error::INJECTED
::warning::pwn
End-to-end PoC running the official binary inside a real GitHub Actions workflow:
https://github.com/hits313/osv-crlf-poc/actions/runs/24805983517
(Workflow file of the runner parsing the injected commands and dropping subsequent annotations are in that repo.)
Impact
Any contributor who can open a pull request against a repository that runs osv-scanner in CI can exploit this. No special permissions are required, and public repositories accepting external PRs are fully exposed. git does not sanitize \r from filenames, and \r is a valid filename byte on Linux, so a malicious PR can simply rename a directory.
In a typical exploit:
- Attacker opens a PR that:
- Renames an existing directory to
legit\r::stop-commands::TOK\r.
- Bumps a dependency to a version with a known CVE.
- CI runs osv-scanner. The first scanned-source line emits the
\r-laden directory name to stdout via Sink 1 (so this triggers regardless of --format).
- The GHA runner parses
::stop-commands::TOK as a live directive and stops processing all further workflow commands.
- Every
::error::CVE-… annotation osv-scanner emits afterwards is dropped. The vulnerability gate reports green.
- Reviewer sees clean check, merges the vulnerable dependency.
Additional primitives the attacker gets for free in the same step:
::error:: / ::warning:: — inject arbitrary annotations that appear to originate from osv-scanner, misleading reviewers.
::add-mask::VALUE — hide arbitrary strings from all subsequent log output in the workflow run.
- On runner images that still accept the deprecated
::set-output:: / ::save-state:: commands, attacker-controlled values can also propagate to downstream steps.
The official google/osv-scanner-action is used as a required status check by hundreds of open-source projects. This issue lets an attacker silently defeat that gate.
Suggested fix
Encode \r (and ideally \n) at the writer boundary, not per-format, so future output paths inherit the protection by default. A targeted fix at internal/cmdlogger/handler.go:79 plus the two internal/output/*.go sinks would close all three known paths:
// internal/cmdlogger/handler.go
msg := strings.NewReplacer("\r", "%0D", "\n", "%0A").Replace(record.Message)
_, err := fmt.Fprint(c.writer(record.Level), msg+"\n")
…and a similar sanitization helper applied to result.Name / source.Name before they hit vertical.go and table.go.
Defense-in-depth note: scanning runs that target untrusted content should arguably never write user-controlled bytes to stdout unsanitized when running under GitHub Actions; detecting GITHUB_ACTIONS=true and applying the encoder unconditionally would prevent regressions of this class.
I'm happy to open a PR with either approach writer-boundary sanitizer (preferred, single source of truth) or per-sink sanitization. Let me know which you'd prefer.
Versions
Reporter hits313
Coordinated disclosure via Google OSS VRP; triage requested public upstream coordination, hence this issue. Happy to provide additional reproducers (including a dedicated ::stop-commands:: end-to-end run) or to open the PR directly if useful.
Summary
PR #2669 (commit c2c9aae) encoded
\ras%0Dfor the--format=gh-annotationsoutput path to fix GitHub Actions workflow-command injection. The fix is incomplete: three other code paths still emit raw\rbytes to stdout when a scanned source path contains\r.Because the GitHub Actions runner treats
\ras a line terminator while parsing::command::valuesequences from stdout, an attacker who can introduce a directory name containing\r::stop-commands::TOKEN\rinto a CI run can:::error::,::warning::,::add-mask::,::stop-commands::).::stop-commands::, causing every later::error::CVE-XXXXannotation that osv-scanner itself emits to be dropped — the PR status check turns green and the maintainer merges believing the dependency change is clean.This effectively defeats osv-scanner's value as a CI gate while the tool appears to run normally.
Confirmed against v2.3.5 (b4e672e, post-patch) by
od -cof stdout raw\rbytes are present.Coordinated disclosure via Google OSS VRP; triage requested public upstream coordination, hence this issue.
Affected sinks (all post-c2c9aae)
All three paths interpolate a user-controlled source/directory path into a string that is written to stdout without
\r/\nencoding.Sink 1 —
pkg/osvscanner/scan.go:153This is emitted on every scan, before any output format is selected — so it fires regardless of whether the user passes
--format=gh-annotationsor not.Infolevel routes to stdout viainternal/cmdlogger/handler.go:79:Sink 2 —
internal/output/vertical.go:158Sink 3 —
internal/output/table.go:143The patched
gh-annotationspath correctly encodes\r→%0D. None of the three sinks above do.Why GitHub Actions is vulnerable to
\rThe GHA runner treats both
\nand\ras line terminators when scanning a step's stdout for::command::valuesequences. A single line containing:is parsed by the runner as three lines, including a live
::stop-commands::TOKdirective. After that directive, all subsequent workflow commands are ignored until the matching::TOK::is emitted — which the attacker can simply choose not to.This is the same class of bug that PR #2669 fixed for
gh-annotations; it just wasn't fixed everywhere.Proof (byte-level)
od -cof osv-scanner stdout when scanning a directory whose name contains\r:Raw
\rbytes in stdout, confirmed against v2.3.5 (b4e672e).Reproducer
Output:
End-to-end PoC running the official binary inside a real GitHub Actions workflow:
https://github.com/hits313/osv-crlf-poc/actions/runs/24805983517
(Workflow file of the runner parsing the injected commands and dropping subsequent annotations are in that repo.)
Impact
Any contributor who can open a pull request against a repository that runs osv-scanner in CI can exploit this. No special permissions are required, and public repositories accepting external PRs are fully exposed.
gitdoes not sanitize\rfrom filenames, and\ris a valid filename byte on Linux, so a malicious PR can simply rename a directory.In a typical exploit:
legit\r::stop-commands::TOK\r.\r-laden directory name to stdout via Sink 1 (so this triggers regardless of--format).::stop-commands::TOKas a live directive and stops processing all further workflow commands.::error::CVE-…annotation osv-scanner emits afterwards is dropped. The vulnerability gate reports green.Additional primitives the attacker gets for free in the same step:
::error::/::warning::— inject arbitrary annotations that appear to originate from osv-scanner, misleading reviewers.::add-mask::VALUE— hide arbitrary strings from all subsequent log output in the workflow run.::set-output::/::save-state::commands, attacker-controlled values can also propagate to downstream steps.The official
google/osv-scanner-actionis used as a required status check by hundreds of open-source projects. This issue lets an attacker silently defeat that gate.Suggested fix
Encode
\r(and ideally\n) at the writer boundary, not per-format, so future output paths inherit the protection by default. A targeted fix atinternal/cmdlogger/handler.go:79plus the twointernal/output/*.gosinks would close all three known paths:…and a similar sanitization helper applied to
result.Name/source.Namebefore they hitvertical.goandtable.go.Defense-in-depth note: scanning runs that target untrusted content should arguably never write user-controlled bytes to stdout unsanitized when running under GitHub Actions; detecting
GITHUB_ACTIONS=trueand applying the encoder unconditionally would prevent regressions of this class.I'm happy to open a PR with either approach writer-boundary sanitizer (preferred, single source of truth) or per-sink sanitization. Let me know which you'd prefer.
Versions
mainat time of report.--format=gh-annotations).Reporter hits313
Coordinated disclosure via Google OSS VRP; triage requested public upstream coordination, hence this issue. Happy to provide additional reproducers (including a dedicated
::stop-commands::end-to-end run) or to open the PR directly if useful.