Skip to content

Commit 7255162

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

File tree

8 files changed

+288
-74
lines changed

8 files changed

+288
-74
lines changed

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

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,17 +32,28 @@ 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 positive integer to prevent arithmetic injection
47+
if ! printf '%s' "${TIMEOUT}" | grep -qE '^[0-9]+$'; then
48+
echo "::error::timeout input must be a positive integer, got: ${TIMEOUT}"
49+
exit 1
50+
fi
51+
URL="http://localhost:${PORT}${BASE}"
4152
echo "Waiting for ${URL} …"
42-
DEADLINE=$(( $(date +%s) + ${{ inputs.timeout }} ))
53+
DEADLINE=$(( $(date +%s) + TIMEOUT ))
4354
until curl -sf "${URL}" > /dev/null 2>&1; do
4455
if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
45-
echo "::error::Preview server did not respond within ${{ inputs.timeout }} s on ${URL}."
56+
echo "::error::Preview server did not respond within ${TIMEOUT} s on ${URL}."
4657
exit 1
4758
fi
4859
echo "… retrying in 2 s"
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: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2-
import { mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
2+
import { existsSync, mkdirSync, 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";
@@ -47,7 +47,10 @@ describe("getCacheDir", () => {
4747
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
4848
const originalLocalAppData = process.env.LOCALAPPDATA;
4949
try {
50-
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
50+
Object.defineProperty(process, "platform", {
51+
value: "win32",
52+
configurable: true,
53+
});
5154
process.env.LOCALAPPDATA = "C:\\Users\\user\\AppData\\Local";
5255
const dir = getCacheDir();
5356
// path.join uses the host OS separator in tests (macOS: /), so we just
@@ -67,7 +70,10 @@ describe("getCacheDir", () => {
6770
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
6871
const originalLocalAppData = process.env.LOCALAPPDATA;
6972
try {
70-
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
73+
Object.defineProperty(process, "platform", {
74+
value: "win32",
75+
configurable: true,
76+
});
7177
delete process.env.LOCALAPPDATA;
7278
const dir = getCacheDir();
7379
expect(dir).toContain("AppData");
@@ -84,7 +90,10 @@ describe("getCacheDir", () => {
8490
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
8591
const originalXdg = process.env.XDG_CACHE_HOME;
8692
try {
87-
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
93+
Object.defineProperty(process, "platform", {
94+
value: "linux",
95+
configurable: true,
96+
});
8897
process.env.XDG_CACHE_HOME = "/custom/xdg/cache";
8998
const dir = getCacheDir();
9099
expect(dir).toContain("custom");
@@ -102,7 +111,10 @@ describe("getCacheDir", () => {
102111
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
103112
const originalXdg = process.env.XDG_CACHE_HOME;
104113
try {
105-
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
114+
Object.defineProperty(process, "platform", {
115+
value: "linux",
116+
configurable: true,
117+
});
106118
delete process.env.XDG_CACHE_HOME;
107119
const dir = getCacheDir();
108120
expect(dir).toContain(".cache");
@@ -117,7 +129,10 @@ describe("getCacheDir", () => {
117129
delete process.env.GITHUB_CODE_SEARCH_CACHE_DIR;
118130
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
119131
try {
120-
Object.defineProperty(process, "platform", { value: "freebsd", configurable: true });
132+
Object.defineProperty(process, "platform", {
133+
value: "freebsd",
134+
configurable: true,
135+
});
121136
const dir = getCacheDir();
122137
expect(dir).toContain(".github-code-search");
123138
expect(dir).toContain("cache");
@@ -224,3 +239,60 @@ describe("writeCache / readCache round-trip", () => {
224239
expect(() => writeCache("key.json", { data: 1 })).not.toThrow();
225240
});
226241
});
242+
243+
// ─── Path Traversal Security Tests ────────────────────────────────────────────
244+
245+
describe("Path traversal vulnerability mitigation", () => {
246+
it("rejects readCache with relative parent directory traversal (../)", () => {
247+
// Attempt to read outside the cache directory using ../
248+
const maliciousKey = "../../../etc/passwd";
249+
const result = readCache(maliciousKey);
250+
expect(result).toBeNull();
251+
});
252+
253+
it("rejects writeCache with relative parent directory traversal (../)", () => {
254+
// Use a subdirectory as cache dir so we have a writable location just above it
255+
const cacheSubDir = join(TEST_CACHE_DIR, "sub");
256+
mkdirSync(cacheSubDir, { recursive: true });
257+
process.env.GITHUB_CODE_SEARCH_CACHE_DIR = cacheSubDir;
258+
// This key resolves to TEST_CACHE_DIR/escaped.json — one level above cacheSubDir
259+
const maliciousKey = "../escaped.json";
260+
const outsidePath = join(TEST_CACHE_DIR, "escaped.json");
261+
expect(() => writeCache(maliciousKey, { exploit: true })).not.toThrow();
262+
// Prove the file was NOT created at the resolved outside location
263+
expect(existsSync(outsidePath)).toBe(false);
264+
});
265+
266+
it("rejects readCache with absolute path", () => {
267+
// Attempt to read an absolute path outside the cache directory
268+
// Use a portable OS temp path that exists on all platforms
269+
const maliciousKey = join(tmpdir(), "gcs-read-absolute-test.json");
270+
const result = readCache(maliciousKey);
271+
expect(result).toBeNull();
272+
});
273+
274+
it("rejects writeCache with absolute path", () => {
275+
// Attempt to write to an absolute path outside the cache directory
276+
const outsidePath = join(tmpdir(), `gcs-write-absolute-test-${process.pid}.json`);
277+
expect(() => writeCache(outsidePath, { exploit: "absolute" })).not.toThrow();
278+
// Prove the file was NOT created at the absolute outside location
279+
expect(existsSync(outsidePath)).toBe(false);
280+
});
281+
282+
it("rejects readCache with encoded path traversal (%2e%2e/)", () => {
283+
// URL-encoded path traversal attempt
284+
const maliciousKey = "%2e%2e/%2e%2e/etc/passwd";
285+
const result = readCache(maliciousKey);
286+
// The key is treated as a literal filename, not decoded, so it's safe
287+
// but should still not exist
288+
expect(result).toBeNull();
289+
});
290+
291+
it("allows reading legitimate cache files with safe names", () => {
292+
// Verify that normal operation still works
293+
const safeKey = "teams__myorg__squad-.json";
294+
const data = { teams: ["squad-alpha"] };
295+
writeCache(safeKey, data);
296+
expect(readCache(safeKey)).toEqual(data);
297+
});
298+
});

src/cache.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
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+
writeFileSync,
10+
} from "node:fs";
11+
import { randomBytes } from "node:crypto";
212
import { homedir } from "node:os";
3-
import { join } from "node:path";
13+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
414

515
// ─── Team-list cache ──────────────────────────────────────────────────────────
616
//
@@ -67,15 +77,27 @@ export function getCacheKey(org: string, prefixes: string[]): string {
6777
* - the file is older than `CACHE_TTL_MS`
6878
*/
6979
export function readCache<T>(key: string): T | null {
70-
const filePath = join(getCacheDir(), key);
80+
const base = resolve(getCacheDir());
81+
const target = resolve(base, key);
82+
const rel = relative(base, target);
83+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return null;
84+
// Fix: open the file once and use fstatSync on the same fd to avoid a
85+
// TOCTOU race condition between the age check and the read.
86+
// O_NOFOLLOW prevents symlink hijacking in world-writable directories
87+
// (CWE-377). On Windows the constant is undefined so we fall back to 0.
88+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
89+
let fd: number | null = null;
7190
try {
72-
const stat = statSync(filePath);
91+
fd = openSync(target, constants.O_RDONLY | O_NOFOLLOW);
92+
const stat = fstatSync(fd);
7393
const ageMs = Date.now() - stat.mtimeMs;
7494
if (ageMs > CACHE_TTL_MS) return null;
75-
const raw = readFileSync(filePath, "utf8");
95+
const raw = readFileSync(fd, "utf8");
7696
return JSON.parse(raw) as T;
7797
} catch {
7898
return null;
99+
} finally {
100+
if (fd !== null) closeSync(fd);
79101
}
80102
}
81103

@@ -86,10 +108,21 @@ export function readCache<T>(key: string): T | null {
86108
* best-effort and must never crash the CLI.
87109
*/
88110
export function writeCache<T>(key: string, data: T): void {
111+
// Fix: write to a randomly-named temp file first, then rename atomically to
112+
// the target path. This avoids symlink/race attacks on the cache directory
113+
// and ensures readers never observe a partially-written file.
89114
try {
90115
const dir = getCacheDir();
116+
const base = resolve(dir);
117+
const target = resolve(base, key);
118+
const rel = relative(base, target);
119+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return;
91120
mkdirSync(dir, { recursive: true });
92-
writeFileSync(join(dir, key), JSON.stringify(data), "utf8");
121+
const tmpPath = `${target}.${randomBytes(6).toString("hex")}.tmp`;
122+
// mode 0o600: owner-only read/write, so the temp file is private even
123+
// when the parent directory is world-writable (CWE-377).
124+
writeFileSync(tmpPath, JSON.stringify(data), { encoding: "utf8", flag: "wx", mode: 0o600 });
125+
renameSync(tmpPath, target);
93126
} catch {
94127
// Best-effort: ignore write errors
95128
}

src/upgrade.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ describe("isNewerVersion", () => {
8080
function makeAsset(name: string): ReleaseAsset {
8181
return {
8282
name,
83-
browser_download_url: `https://example.com/releases/${name}`,
83+
browser_download_url: `https://github.com/fulll/github-code-search/releases/download/v9.9.9/${name}`,
8484
};
8585
}
8686

src/upgrade.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,38 @@ export async function fetchLatestRelease(
121121
* Uses a ".tmp" sibling file so the replacement is atomic on the same FS.
122122
*/
123123
async function downloadBinary(url: string, dest: string, debug = false): Promise<void> {
124+
// Validate URL to prevent SSRF attacks
125+
const allowedDomains = ["github.com"];
126+
try {
127+
const parsedUrl = new URL(url);
128+
if (!allowedDomains.includes(parsedUrl.hostname)) {
129+
throw new Error("Invalid host");
130+
}
131+
if (parsedUrl.protocol !== "https:") {
132+
throw new Error("Invalid protocol");
133+
}
134+
} catch {
135+
throw new Error("Invalid URL");
136+
}
137+
124138
if (debug) process.stdout.write(`[debug] downloading from ${url}\n`);
125139
const res = await fetch(url);
126140
if (debug)
127141
process.stdout.write(
128142
`[debug] fetch response: status=${res.status} ok=${res.ok} url=${res.url}\n`,
129143
);
144+
// Validate the final URL after potential redirects to guard against redirect-based SSRF.
145+
// res.url is empty for mocked responses (tests), so we only validate when it is set.
146+
if (res.url) {
147+
try {
148+
const finalUrl = new URL(res.url);
149+
if (!allowedDomains.includes(finalUrl.hostname) || finalUrl.protocol !== "https:") {
150+
throw new Error("Redirect blocked");
151+
}
152+
} catch {
153+
throw new Error("Redirect blocked: final URL does not match allowed domains");
154+
}
155+
}
130156
if (!res.ok) {
131157
throw new Error(`Download failed (${res.status}): ${url}`);
132158
}

0 commit comments

Comments
 (0)