Skip to content

Commit 6ed953b

Browse files
committed
security(aikido): implement and fix detected vulnerabities
1 parent 57a506f commit 6ed953b

File tree

9 files changed

+477
-80
lines changed

9 files changed

+477
-80
lines changed

.github/actions/start-preview-server/action.yml

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,43 @@ runs:
3232
steps:
3333
- name: Start VitePress preview
3434
shell: bash
35-
run: bun run docs:preview -- --port ${{ inputs.port }} &
35+
env:
36+
PORT: ${{ inputs.port }}
37+
run: bun run docs:preview -- --port "$PORT" &
3638

3739
- name: Wait for preview server to be ready
3840
shell: bash
41+
env:
42+
PORT: ${{ inputs.port }}
43+
BASE: ${{ inputs.base }}
44+
TIMEOUT: ${{ inputs.timeout }}
3945
run: |
40-
URL="http://localhost:${{ inputs.port }}${{ inputs.base }}"
46+
# Validate TIMEOUT is a strictly positive integer to prevent arithmetic injection
47+
if ! printf '%s' "${TIMEOUT}" | grep -qE '^[1-9][0-9]*$'; then
48+
echo "::error::timeout input must be a strictly positive integer, got: ${TIMEOUT}"
49+
exit 1
50+
fi
51+
# Validate PORT is a strictly positive integer to prevent userinfo injection (e.g. 80@attacker.tld)
52+
if ! printf '%s' "${PORT}" | grep -qE '^[1-9][0-9]*$'; then
53+
echo "::error::port input must be a strictly positive integer, got: ${PORT}"
54+
exit 1
55+
fi
56+
# Validate PORT is within the valid TCP port range to avoid unreachable URLs
57+
if [ "${PORT}" -lt 1 ] || [ "${PORT}" -gt 65535 ]; then
58+
echo "::error::port input must be between 1 and 65535, got: ${PORT}"
59+
exit 1
60+
fi
61+
# Validate BASE starts with / and contains no spaces to prevent URL injection
62+
if ! printf '%s' "${BASE}" | grep -qE '^/[^ ]*$'; then
63+
echo "::error::base input must start with / and contain no spaces, got: ${BASE}"
64+
exit 1
65+
fi
66+
URL="http://localhost:${PORT}${BASE}"
4167
echo "Waiting for ${URL} …"
42-
DEADLINE=$(( $(date +%s) + ${{ inputs.timeout }} ))
68+
DEADLINE=$(( $(date +%s) + TIMEOUT ))
4369
until curl -sf "${URL}" > /dev/null 2>&1; do
4470
if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
45-
echo "::error::Preview server did not respond within ${{ inputs.timeout }} s on ${URL}."
71+
echo "::error::Preview server did not respond within ${TIMEOUT} s on ${URL}."
4672
exit 1
4773
fi
4874
echo "… retrying in 2 s"

.github/dependabot.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# To get started with Dependabot version updates, you'll need to specify which
2+
# package ecosystems to update and where the package manifests are located.
3+
# Please see the documentation for all configuration options:
4+
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5+
6+
version: 2
7+
updates:
8+
- package-ecosystem: "bun" # Covers package.json / bun.lockb managed via Bun
9+
directory: "/" # Location of package manifests
10+
schedule:
11+
interval: "weekly"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
applyTo: "**"
3+
description: Aikido MCP Security Rules
4+
---
5+
6+
# Project security best practices
7+
8+
- Always run **aikido_full_scan** on generated, added and modified first-party code, unless the prompt explicitly says not to.
9+
- Always provide the full file content to the scanner.
10+
- If the scan identifies any security issues in newly generated or modified code, fix the issues using the scan result and remediation provided by **Aikido**.
11+
- After applying fixes, run aikido_full_scan again to verify that the issues were successfully resolved, and no new issues were introduced.
12+
- Repeat the fix-and-rescan cycle until the code passes with zero remaining or newly introduced security issues.
13+
- If the **Aikido MCP server** required to run scans is not installed or not started successfully, inform the user with a message directing them to install it using the official Aikido MCP setup guide at:
14+
**https://help.aikido.dev/ide-plugins/aikido-mcp**.

bun.lock

Lines changed: 104 additions & 51 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,14 +59,19 @@
5959
"@lhci/cli": "^0.15.1",
6060
"@playwright/test": "^1.58.2",
6161
"@resvg/resvg-js": "^2.6.2",
62-
"bun-types": "^1.3.10",
63-
"knip": "^5.86.0",
62+
"bun-types": "^1.3.11",
63+
"knip": "^6.0.6",
6464
"mermaid": "^11.13.0",
65-
"oxfmt": "^0.40.0",
66-
"oxlint": "^1.55.0",
65+
"oxfmt": "^0.42.0",
66+
"oxlint": "^1.57.0",
6767
"pa11y-ci": "^4.1.0",
6868
"sharp": "^0.34.5",
69-
"vitepress": "^1.6.3",
70-
"vitepress-mermaid-renderer": "^1.1.18"
69+
"vitepress": "^1.6.4",
70+
"vitepress-mermaid-renderer": "^1.1.20"
71+
},
72+
"overrides": {
73+
"brace-expansion": "1.1.13",
74+
"dompurify": "3.3.2",
75+
"undici": "7.24.1"
7176
}
7277
}

src/cache.test.ts

Lines changed: 84 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2-
import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
2+
import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { CACHE_TTL_MS, getCacheDir, getCacheKey, readCache, writeCache } from "./cache.ts";
66

7-
// Use env-var override to redirect the cache to an isolated temp directory
8-
const TEST_CACHE_DIR = join(tmpdir(), `gcs-cache-test-${process.pid}`);
7+
// Use env-var override to redirect the cache to a process-private temp directory.
8+
// mkdtempSync creates the directory with 0o700 permissions (owner-only access),
9+
// which is the safe temp-dir pattern recognised by static analysers (CWE-377).
10+
let TEST_CACHE_DIR: string;
911

1012
beforeEach(() => {
13+
TEST_CACHE_DIR = mkdtempSync(join(tmpdir(), "gcs-cache-test-"));
1114
process.env.GITHUB_CODE_SEARCH_CACHE_DIR = TEST_CACHE_DIR;
12-
mkdirSync(TEST_CACHE_DIR, { recursive: true });
1315
});
1416

1517
afterEach(() => {
@@ -47,7 +49,10 @@ describe("getCacheDir", () => {
4749
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
4850
const originalLocalAppData = process.env.LOCALAPPDATA;
4951
try {
50-
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
52+
Object.defineProperty(process, "platform", {
53+
value: "win32",
54+
configurable: true,
55+
});
5156
process.env.LOCALAPPDATA = "C:\\Users\\user\\AppData\\Local";
5257
const dir = getCacheDir();
5358
// path.join uses the host OS separator in tests (macOS: /), so we just
@@ -67,7 +72,10 @@ describe("getCacheDir", () => {
6772
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
6873
const originalLocalAppData = process.env.LOCALAPPDATA;
6974
try {
70-
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
75+
Object.defineProperty(process, "platform", {
76+
value: "win32",
77+
configurable: true,
78+
});
7179
delete process.env.LOCALAPPDATA;
7280
const dir = getCacheDir();
7381
expect(dir).toContain("AppData");
@@ -84,7 +92,10 @@ describe("getCacheDir", () => {
8492
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
8593
const originalXdg = process.env.XDG_CACHE_HOME;
8694
try {
87-
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
95+
Object.defineProperty(process, "platform", {
96+
value: "linux",
97+
configurable: true,
98+
});
8899
process.env.XDG_CACHE_HOME = "/custom/xdg/cache";
89100
const dir = getCacheDir();
90101
expect(dir).toContain("custom");
@@ -102,7 +113,10 @@ describe("getCacheDir", () => {
102113
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
103114
const originalXdg = process.env.XDG_CACHE_HOME;
104115
try {
105-
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
116+
Object.defineProperty(process, "platform", {
117+
value: "linux",
118+
configurable: true,
119+
});
106120
delete process.env.XDG_CACHE_HOME;
107121
const dir = getCacheDir();
108122
expect(dir).toContain(".cache");
@@ -117,7 +131,10 @@ describe("getCacheDir", () => {
117131
delete process.env.GITHUB_CODE_SEARCH_CACHE_DIR;
118132
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
119133
try {
120-
Object.defineProperty(process, "platform", { value: "freebsd", configurable: true });
134+
Object.defineProperty(process, "platform", {
135+
value: "freebsd",
136+
configurable: true,
137+
});
121138
const dir = getCacheDir();
122139
expect(dir).toContain(".github-code-search");
123140
expect(dir).toContain("cache");
@@ -224,3 +241,61 @@ describe("writeCache / readCache round-trip", () => {
224241
expect(() => writeCache("key.json", { data: 1 })).not.toThrow();
225242
});
226243
});
244+
245+
// ─── Path Traversal Security Tests ────────────────────────────────────────────
246+
247+
describe("Path traversal vulnerability mitigation", () => {
248+
it("rejects readCache with relative parent directory traversal (../)", () => {
249+
// Attempt to read outside the cache directory using ../
250+
const maliciousKey = "../../../etc/passwd";
251+
const result = readCache(maliciousKey);
252+
expect(result).toBeNull();
253+
});
254+
255+
it("rejects writeCache with relative parent directory traversal (../)", () => {
256+
// Use a subdirectory as cache dir so we have a writable location just above it
257+
const cacheSubDir = join(TEST_CACHE_DIR, "sub");
258+
mkdirSync(cacheSubDir, { recursive: true });
259+
process.env.GITHUB_CODE_SEARCH_CACHE_DIR = cacheSubDir;
260+
// This key resolves to TEST_CACHE_DIR/escaped.json — one level above cacheSubDir
261+
const maliciousKey = "../escaped.json";
262+
const outsidePath = join(TEST_CACHE_DIR, "escaped.json");
263+
expect(() => writeCache(maliciousKey, { exploit: true })).not.toThrow();
264+
// Prove the file was NOT created at the resolved outside location
265+
expect(existsSync(outsidePath)).toBe(false);
266+
});
267+
268+
it("rejects readCache with absolute path", () => {
269+
// Attempt to read an absolute path outside the cache directory
270+
// Use a portable OS temp path that exists on all platforms
271+
const maliciousKey = join(tmpdir(), "gcs-read-absolute-test.json");
272+
const result = readCache(maliciousKey);
273+
expect(result).toBeNull();
274+
});
275+
276+
it("rejects writeCache with absolute path", () => {
277+
// Attempt to write to an absolute path outside the cache directory
278+
const outsidePath = join(tmpdir(), `gcs-write-absolute-test-${process.pid}.json`);
279+
expect(() => writeCache(outsidePath, { exploit: "absolute" })).not.toThrow();
280+
// Prove the file was NOT created at the absolute outside location
281+
expect(existsSync(outsidePath)).toBe(false);
282+
});
283+
284+
it("rejects readCache with encoded path traversal (%2e%2e/)", () => {
285+
// URL-encoded path traversal attempt
286+
const maliciousKey = "%2e%2e/%2e%2e/etc/passwd";
287+
const result = readCache(maliciousKey);
288+
// URL-encoded ".." segments that also contain a literal "/" path separator.
289+
// readCache rejects it because the key contains a literal "/" (separator check),
290+
// not because it decodes the percent-encoded characters.
291+
expect(result).toBeNull();
292+
});
293+
294+
it("allows reading legitimate cache files with safe names", () => {
295+
// Verify that normal operation still works
296+
const safeKey = "teams__myorg__squad-.json";
297+
const data = { teams: ["squad-alpha"] };
298+
writeCache(safeKey, data);
299+
expect(readCache(safeKey)).toEqual(data);
300+
});
301+
});

src/cache.ts

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,17 @@
1-
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
1+
import {
2+
closeSync,
3+
constants,
4+
fstatSync,
5+
mkdirSync,
6+
openSync,
7+
readFileSync,
8+
renameSync,
9+
unlinkSync,
10+
writeFileSync,
11+
} from "node:fs";
12+
import { randomBytes } from "node:crypto";
213
import { homedir } from "node:os";
3-
import { join } from "node:path";
14+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
415

516
// ─── Team-list cache ──────────────────────────────────────────────────────────
617
//
@@ -67,15 +78,33 @@ export function getCacheKey(org: string, prefixes: string[]): string {
6778
* - the file is older than `CACHE_TTL_MS`
6879
*/
6980
export function readCache<T>(key: string): T | null {
70-
const filePath = join(getCacheDir(), key);
81+
// Reject keys containing path separators or absolute-path markers
82+
// before any filesystem operation (defence-in-depth for static analysers).
83+
if (key.includes("/") || key.includes("\\") || isAbsolute(key)) return null;
84+
const base = resolve(getCacheDir());
85+
const target = resolve(base, key);
86+
const rel = relative(base, target);
87+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return null;
88+
// Fix: open the file once and use fstatSync on the same fd to avoid a
89+
// TOCTOU race condition between the age check and the read.
90+
// O_NOFOLLOW prevents symlink hijacking in world-writable directories
91+
// (CWE-377). On Windows the constant is undefined so we fall back to 0.
92+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
93+
let fd: number | null = null;
7194
try {
72-
const stat = statSync(filePath);
95+
fd = openSync(target, constants.O_RDONLY | O_NOFOLLOW);
96+
const stat = fstatSync(fd);
97+
// Verify the descriptor points to a regular file — prevents reading from
98+
// FIFOs or device files that could block or expose unintended data (CWE-377).
99+
if (!stat.isFile()) return null;
73100
const ageMs = Date.now() - stat.mtimeMs;
74101
if (ageMs > CACHE_TTL_MS) return null;
75-
const raw = readFileSync(filePath, "utf8");
102+
const raw = readFileSync(fd, "utf8");
76103
return JSON.parse(raw) as T;
77104
} catch {
78105
return null;
106+
} finally {
107+
if (fd !== null) closeSync(fd);
79108
}
80109
}
81110

@@ -86,10 +115,34 @@ export function readCache<T>(key: string): T | null {
86115
* best-effort and must never crash the CLI.
87116
*/
88117
export function writeCache<T>(key: string, data: T): void {
118+
// Fix: write to a randomly-named temp file first, then rename atomically to
119+
// the target path. This avoids symlink/race attacks on the cache directory
120+
// and ensures readers never observe a partially-written file.
89121
try {
122+
// Reject keys containing path separators or absolute-path markers
123+
// before any filesystem operation (defence-in-depth for static analysers).
124+
if (key.includes("/") || key.includes("\\") || isAbsolute(key)) return;
90125
const dir = getCacheDir();
91-
mkdirSync(dir, { recursive: true });
92-
writeFileSync(join(dir, key), JSON.stringify(data), "utf8");
126+
const base = resolve(dir);
127+
const target = resolve(base, key);
128+
const rel = relative(base, target);
129+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return;
130+
mkdirSync(dir, { recursive: true, mode: 0o700 });
131+
const tmpPath = `${target}.${randomBytes(6).toString("hex")}.tmp`;
132+
// mode 0o600: owner-only read/write, so the temp file is private even
133+
// when the parent directory is world-writable (CWE-377).
134+
writeFileSync(tmpPath, JSON.stringify(data), { encoding: "utf8", flag: "wx", mode: 0o600 });
135+
// Fix: on Windows, renameSync fails if the destination already exists
136+
// (unlike POSIX where it is atomic). Unlink the target first on win32
137+
// to restore replace semantics without sacrificing atomicity on POSIX.
138+
if (process.platform === "win32") {
139+
try {
140+
unlinkSync(target);
141+
} catch {
142+
// Target may not exist yet — ignore
143+
}
144+
}
145+
renameSync(tmpPath, target);
93146
} catch {
94147
// Best-effort: ignore write errors
95148
}

0 commit comments

Comments
 (0)