Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 2024-06-09 - Path Traversal Vulnerability in `exportGherkin`
**Vulnerability:** The `--output` argument in `vspec export gherkin` does not sanitize the `output` file path. It is concatenated with the config root directly (`join(config.root, output)`).
**Learning:** This allows an attacker to provide a path like `../../../../etc/passwd` to write Gherkin export data to unauthorized locations outside the project directory.
**Prevention:** Implement strict input validation on constructed paths to verify they remain within the expected directory bounds. Use `path.resolve` and check if the resolved relative path starts with `..` or is an absolute path.
9 changes: 7 additions & 2 deletions src/export/gherkin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { dirname, join, resolve, relative, isAbsolute } from "node:path";
import { findUseCaseFile, readConfig, relativePath } from "../files.js";
import { parseUseCaseMarkdown } from "../format/parse.js";
import { VspecError } from "../errors.js";
import type { ParsedUseCase } from "../domain/types.js";

export function renderGherkin(useCase: ParsedUseCase): string {
Expand Down Expand Up @@ -39,7 +40,11 @@ export function exportGherkin(args: { key: string; output?: string; cwd?: string
if (!source) throw new Error("KEY_NOT_FOUND");
const text = renderGherkin(parseUseCaseMarkdown(readFileSync(source, "utf8")));
const output = args.output ?? join("tests", `${args.key}.feature`);
const outputPath = join(config.root, output);
const outputPath = resolve(config.root, output);
const rel = relative(config.root, outputPath);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new VspecError("INVALID_ARGUMENT", "Output path must be within the project root.");
}
Comment on lines +43 to +47

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# Description: Check for symlinks in the repository and assess security impact

# Find all symlinks in the repository
echo "=== Symlinks in repository ==="
fd -t l -H

# Check if the codebase has any symlink-related security documentation
echo -e "\n=== Symlink security documentation ==="
rg -i "symlink" --type md -C 2

Repository: Seongho-Bae/vooster-v2-mvp

Length of output: 141


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the referenced lines
echo "=== src/export/gherkin.ts (lines 1-120) ==="
nl -ba src/export/gherkin.ts | sed -n '1,120p'

# Show where config.root and output come from (best-effort)
echo -e "\n=== Search for config.root usage in src/export/gherkin.ts ==="
rg -n "config\.root" src/export/gherkin.ts

echo -e "\n=== Search for output variable derivation in src/export/gherkin.ts ==="
rg -n "output" src/export/gherkin.ts

# Check for existing realpath / realpathSync usage across the repo
echo -e "\n=== Search for realpath(realpathSync) usage in src ==="
rg -n "realpath(Sync)?\(" src

# Search for other path boundary validation patterns in src
echo -e "\n=== Search for resolve+relative boundary checks patterns in src ==="
rg -n "relative\\(.*config\\.root|relative\\(.*root|INVALID_ARGUMENT.*Output path|startsWith\\(\"\\.\\.\"\\).*isAbsolute" src || true

Repository: Seongho-Bae/vooster-v2-mvp

Length of output: 175


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file around the referenced lines
echo "=== src/export/gherkin.ts (lines 1-200) ==="
awk '{printf "%d:%s\n", NR, $0}' src/export/gherkin.ts | sed -n '1,200p'

# Show where config.root and output come from (best-effort)
echo -e "\n=== Search for config.root usage in src/export/gherkin.ts ==="
rg -n "config\.root" src/export/gherkin.ts || true

echo -e "\n=== Search for output variable derivation in src/export/gherkin.ts ==="
rg -n "\boutput\b" src/export/gherkin.ts || true

# Check for existing realpath / realpathSync usage across the repo
echo -e "\n=== Search for realpath(realpathSync) usage in src ==="
rg -n "realpath(Sync)?\(" src || true

# Search for other path boundary validation patterns in src
echo -e "\n=== Search for resolve+relative boundary checks patterns in src ==="
rg -n "relative\(|resolve\(|INVALID_ARGUMENT|Output path must be within|startsWith\(\"\\.\\.\"\\)|isAbsolute\(" src || true

Repository: Seongho-Bae/vooster-v2-mvp

Length of output: 5884


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

echo "=== src/files.ts (head/tail + key parts) ==="
wc -l src/files.ts
sed -n '1,120p' src/files.ts
sed -n '120,260p' src/files.ts

echo -e "\n=== src/validate/doctor.ts (root/target ์ฒ˜๋ฆฌ) ==="
wc -l src/validate/doctor.ts
sed -n '1,160p' src/validate/doctor.ts

echo -e "\n=== src/project.ts (root resolve ์—ฌ๋ถ€) ==="
wc -l src/project.ts
sed -n '1,120p' src/project.ts

Repository: Seongho-Bae/vooster-v2-mvp

Length of output: 12646


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

echo -e "\n=== exportGherkin ํ˜ธ์ถœ ์ง€์  ๊ฒ€์ƒ‰ ==="
rg -n "exportGherkin\\(" src || true

echo -e "\n=== args\\.output ๋˜๋Š” --output ๊ด€๋ จ CLI ์˜ต์…˜ ๊ฒ€์ƒ‰ ==="
rg -n "--output|args\\.output|output\\?:" src || true

echo -e "\n=== src/usecase-commands.ts ๊ด€๋ จ ์ถœ๋ ฅ ๊ฒฝ๋กœ ์ƒ์„ฑ/๊ฒ€์ฆ ==="
rg -n "output" src/usecase-commands.ts || true

echo -e "\n=== src/cli.ts ์˜ต์…˜/์ปค๋งจ๋“œ์—์„œ output ์ฒ˜๋ฆฌ ๊ฒ€์ƒ‰ ==="
rg -n "output" src/cli.ts || true

echo -e "\n=== src/output.ts / ๊ธฐํƒ€ ํŒŒ์ผ์—์„œ ๊ฒฝ๋กœ ๊ฒ€์ฆ ๋กœ์ง ๊ฒ€์ƒ‰ ==="
rg -n "INVALID_ARGUMENT.*Output|Output path|realpath|symlink|lstat|lstatSync|realpathSync|resolve\\(.*root" src || true

Repository: Seongho-Bae/vooster-v2-mvp

Length of output: 2038


๐Ÿ Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant CLI output option handling around the reported line numbers
wc -l src/cli.ts
sed -n '240,320p' src/cli.ts

# Inspect where outputSuccess/outputError wrapping happens (might contain validation)
sed -n '1,120p' src/output.ts
wc -l src/output.ts
sed -n '1,140p' src/output.ts

# Double-check no other boundary validation exists for output paths
rg -n "realpath(Sync)?\\(|lstat(Sync)?\\(|readlink(Sync)?\\(|symlink" src || true
rg -n "Output path must be within|INVALID_ARGUMENT\".*,.*Output|--output|options\\.output|exportGherkin\\(\\{ key, output" src/cli.ts src/export/gherkin.ts || true

Repository: Seongho-Bae/vooster-v2-mvp

Length of output: 11940


[๊ฒฝ๋กœ ๊ฒ€์ฆ ์šฐํšŒ ๊ฐ€๋Šฅ์„ฑ ๋ณด๊ฐ• ํ•„์š”] src/export/gherkin.ts (43-47) ์˜ resolve + relative + (..)/isAbsolute ๊ฒ€์ฆ์€ ๋ฌธ์ž์—ด ๊ฒฝ๋กœ ๊ฒฝ๊ณ„๋งŒ ํ™•์ธํ•˜๋ฏ€๋กœ, config.root ๋‚ด๋ถ€์— ์žˆ๋Š” ๋””๋ ‰ํ„ฐ๋ฆฌ/๊ฒฝ๋กœ ๊ตฌ์„ฑ์š”์†Œ๊ฐ€ symlink์œผ๋กœ ์™ธ๋ถ€๋ฅผ ๊ฐ€๋ฆฌํ‚ค๋Š” ๊ฒฝ์šฐ mkdirSync(dirname(outputPath)) ๋ฐ writeFileSync(outputPath, ...)๊ฐ€ ํ”„๋กœ์ ํŠธ ๋ฃจํŠธ ๋ฐ–์— ํŒŒ์ผ์„ ์ƒ์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์ฝ”๋“œ๋ฒ ์ด์Šค์—์„œ ์ด ๊ฒฝ๋กœ ๊ฒ€์ฆ์„ realpathSync/realpath์ฒ˜๋Ÿผ ์‹ค์ œ ๊ฒฝ๋กœ( symlink ํ•ด์„ ) ๊ธฐ์ค€์œผ๋กœ ๋‹ค์‹œ ํ™•์ธํ•˜๋Š” ์ฒ˜๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. fs.realpathSync()(๋˜๋Š” ์กด์žฌํ•˜๋Š” ์ƒ์œ„ ๊ฒฝ๋กœ ๊ธฐ์ค€์œผ๋กœ realpath ํ›„)๋กœ ์‹ค์ œ ๊ฒฝ๋กœ๋ฅผ ๋น„๊ตํ•˜๊ฑฐ๋‚˜, ๊ฒฝ๋กœ ์„ธ๊ทธ๋จผํŠธ๋ณ„๋กœ lstatSync๋กœ symlink์„ ์ฐจ๋‹จํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ๋ณด์™„ํ•˜์„ธ์š”.

๐Ÿค– Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/export/gherkin.ts` around lines 43 - 47, The current check uses
resolve/relative and isAbsolute on outputPath but does not resolve symlinks, so
an attacker can escape the project root via symlinked directories; update the
validation around outputPath (the computed outputPath and rel variables used
before throwing VspecError) to compare real filesystem paths: obtain real paths
for config.root and the output's nearest existing ancestor (using
fs.realpathSync or async fs.realpath) and ensure the real output path starts
with the real root, or alternatively walk path segments of outputPath and
lstatSync each segment to reject any symlinked component before calling
mkdirSync(dirname(outputPath)) or writeFileSync(outputPath,...). Ensure the same
checks are applied wherever outputPath is created/used.

mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, text);
return { key: args.key, text, path: relativePath(outputPath, config.root) };
Expand Down
12 changes: 11 additions & 1 deletion tests/gherkin.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { renderGherkin } from "../src/export/gherkin.js";
import { renderGherkin, exportGherkin } from "../src/export/gherkin.js";
import { parseUseCaseMarkdown } from "../src/format/parse.js";
import { VspecError } from "../src/errors.js";

describe("gherkin export", () => {
it("renders the golden feature byte-for-byte", () => {
const useCase = parseUseCaseMarkdown(readFileSync(join(import.meta.dirname, "fixtures/export/VSPEC-010-export-gherkin.md"), "utf8"));
const expected = readFileSync(join(import.meta.dirname, "fixtures/export/VSPEC-010.feature"), "utf8");
expect(renderGherkin(useCase)).toBe(expected);
});

it("prevents path traversal vulnerabilities", () => {
// VSPEC-001 is present in the mock project root (tests/fixtures/doctor/clean) so it would succeed
// if not for the traversal in the --output path.
const cwd = join(import.meta.dirname, "fixtures/doctor/clean");
expect(() =>
exportGherkin({ key: "VSPEC-001", output: "../../../tmp/hacked.feature", cwd })
).toThrowError(new VspecError("INVALID_ARGUMENT", "Output path must be within the project root."));
});
});