Skip to content

fix(security): act on the first CodeQL baseline (TOCTOU + unquoted setup path) - #51

Merged
StvLi merged 5 commits into
mainfrom
fix/codeql-security-hardening
Sep 29, 2026
Merged

StvLi merged 5 commits into
mainfrom
fix/codeql-security-hardening

Conversation

@StvLi

@StvLi StvLi commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Acting on the first CodeQL baseline (#50). That run produced 29 alerts; this PR fixes the two that are real defects in this repository's own runtime code and stops the generated-artifact noise that was hiding them. Baseline now has a triage instead of a number.

Fixed

1. js/file-system-race — ros2_interface_create could be made to overwrite

packages/core/src/tools.ts

The tool documents "文件已存在,不覆盖" and enforced it with access() followed by a plain writeFile. That is a TOCTOU: between the check and the write a concurrent call — or a symlink planted at the path — can create the target, and the write silently clobbers it.

await access(filePath)                       // check …
await writeFile(filePath, content, 'utf8')   // … then use — racy

Now created with flag: 'wx' (O_CREAT|O_EXCL), which makes the check and the creation one atomic syscall. EEXIST maps back to the same FILE_EXISTS result, so callers see no change. Two new tests: an existing file keeps its original bytes, and a symlink at the target is not followed out of the output root.

2. js/shell-command-constructed-from-input — auto-detected setup path was unquoted

packages/common/src/runner.ts

resolveSetup built source ${auto} && from the auto-detected path with no quoting, while ros2_workspace use has always run its input through shq(). Both are prepended to a bash -lc string, so the two disagreed about the same hazard: a workspaceRoot with a space broke the command, and a metacharacter would have been interpolated into the shell string.

Both auto-detect returns now use shq(). sourcePath still carries the raw filesystem path (consumers existsSync it); the prefix carries it as one quoted shell word. The old fallback test asserted the unquoted form, so it is updated, and a new test uses a root containing a space and a ;.

Triage of the other 27

Rule Count Where Verdict
js/remote-property-injection 12 docs/archify/bridge.html Generated artifact — now excluded
js/insecure-temporary-file 6 5 × test files, 1 × scripts/verification/measure.mjs Test/dev-script fixtures under a private mkdtemp dir; no production path. Not this repository's runtime surface
js/polynomial-redos 3 parse.ts ×2, toolkit.ts Triaged, not fixed — see below
js/file-access-to-http 2 vision.ts By design — image bytes are the request body to the configured VLM gateway
js/shell-command-injection-from-environment 1 runner.ts:38 By design — this is the shell runner; arguments are shq()-quoted by callers
js/file-system-race 1 tools.ts:432 Fixed above

On the ReDoS alerts, deliberately not fixed here. All three are (\w+): / (\S+)(?:…)?$ style patterns over ros2 CLI output. Real exploitability is low — the input is a local CLI's stdout — but it is not zero for parseSafetyEcho, whose input is an echoed ROS topic a publisher could craft. Rewriting the parsers to be provably linear changes the shape of parsing that the rest of the tool set depends on, and that deserves its own change with its own tests rather than being smuggled into a security PR. Left visible on purpose.

Verification

Step Result
pnpm run typecheck pass (9 packages)
pnpm run test 346 passed + 1 pty-skip = 347 (was 343 + 1; +3)
pnpm audit --prod --audit-level high --registry=https://registry.npmjs.org no known vulnerabilities
CodeQL this PR re-runs both languages; the next baseline should drop to ~17 with only triaged rules left

The tool documents "文件已存在,不覆盖", and enforced it by `access()`-ing the
path and then writing with a plain `writeFile`. Those two steps are a TOCTOU:
between the check and the write, a concurrent call — or a symlink planted at
the path — can create the target, and the write then clobbers it silently. The
promise was therefore only true when nothing else touched the path in between.

Found by the CodeQL baseline added in #50 (`js/file-system-race`, the only one
of the 29 first-baseline alerts that sat in this repository's own runtime code
rather than generated docs, test fixtures or by-design flows).

Create with `flag: 'wx'` (O_CREAT|O_EXCL) instead, which makes existence-check
and creation one atomic syscall: the kernel refuses when the path is occupied,
including by a dangling symlink. `EEXIST` maps back to the same FILE_EXISTS
result the `access()` branch returned, so callers see no change.

Covered by two new tests: an existing file keeps its original bytes, and a
symlink at the target path does not get followed out of the output root.
`resolveSetup` built `source ${auto} && ` from the auto-detected path with no
quoting, while the `ros2_workspace use` path (`setSessionRosSetup`) has always
run its input through `shq()`. Both end up prepended to a `bash -lc` string, so
the two disagreed about the same hazard: a `workspaceRoot` carrying a space
broke the command, and a metacharacter would have been interpolated straight
into the shell string.

Surfaced by the CodeQL baseline (#50) as
`js/shell-command-constructed-from-input` on the auto-detect branch.

Quote both auto-detect returns with `shq()`. `sourcePath` still carries the raw
filesystem path — consumers `existsSync` it — while the prefix carries the same
path as one safely-quoted shell word.

The existing fallback test asserted the old unquoted prefix, so it is updated
to expect a quoted one, and a new test uses a workspace root containing both a
space and a `;` to pin the property.
The first baseline (from #50) reported 29 alerts, 12 of them in
`docs/archify/bridge.html` — a 643 KB GENERATED bundle produced by the archify
skill, not source anyone here reviews. That volume buries the alerts that are
actually about this repository, which is the entire value of the baseline.

Add a CodeQL config with `paths-ignore: docs/archify/**` and point `init` at it.
Nothing else is excluded and no query filters are set, so the rest of the tree
stays in scope.

The two real findings in our own runtime code from that same baseline are fixed
in the preceding commits, which is what makes excluding the generated file a
triage decision rather than sweeping something under it.
Comment thread packages/common/tests/runner.spec.ts Fixed
The `CodeQL` pull-request check failed this PR with "1 new alert including 1
high severity security vulnerability", annotated at `runner.spec.ts:73`:

    Insecure temporary file — Insecure creation of file in the os temp dir.

That is the new quoting test, which built a fixed
`/tmp/dsh-runner auto ws;${process.pid}` path. Correct catch, and about my own
change rather than pre-existing code: a predictable name under the temp dir is
world-influenceable, and two concurrent runs of the suite would collide on it.

Use `mkdtempSync(path.join(tmpdir(), 'dsh-runner auto ws;'))`, which keeps both
the space and the `;` in the generated path — the properties the test exists to
exercise — and adds a `finally` that removes the tree.

The pre-existing fixed paths in `tempSetup()` are left alone: they are on main
already, were triaged as test-only fixtures, and are not this PR's business.
Adds §21 and refreshes the header: this round's start/end snapshots, the step-2
re-verification of issue #40 (still accurate; 6 dead links, not 5), the four
PRs, the Dependabot triage, the phoenix loop with the gen-24 restart request,
the full security table, and nine findings.

Three things in it are worth reading even if the rest is skimmed:

  - §21.3.1 — the `__pycache__` publish guard was applied only to the three
    packages that happened to have one, with measured before/after tarball
    evidence that `moveit` leaked and that `safety`'s narrower guard missed a
    bare `.pyc`;
  - §21.3.3 — the first CodeQL baseline returned 29 alerts and two real defects,
    both fixed, plus the reasoning for deliberately NOT fixing the three ReDoS
    ones in the same change;
  - §21.5 — why the obvious fix for the "rebuilt lib, no way to tell whether the
    process reloaded" blind spot would not have caught this round's own drift,
    and so was designed and documented rather than half-built.

Also records two methodology lessons: `gh issue view` fails on gh 2.45.0
(Projects classic deprecation) and needs `gh api`, and a `*.py` glob silently
misses this repository's six extensionless Python scripts.
@StvLi
StvLi merged commit 9e95fa5 into main Sep 29, 2026
5 checks passed
@StvLi
StvLi deleted the fix/codeql-security-hardening branch September 29, 2026 20:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants