Skip to content

Commit 9b5d870

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

File tree

9 files changed

+316
-78
lines changed

9 files changed

+316
-78
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 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+
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.

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: "" # See documentation for possible values
9+
directory: "/" # Location of package manifests
10+
schedule:
11+
interval: "weekly"

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: 83 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,60 @@ 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+
// The key is treated as a literal filename, not decoded, so it's safe
289+
// but should still not exist
290+
expect(result).toBeNull();
291+
});
292+
293+
it("allows reading legitimate cache files with safe names", () => {
294+
// Verify that normal operation still works
295+
const safeKey = "teams__myorg__squad-.json";
296+
const data = { teams: ["squad-alpha"] };
297+
writeCache(safeKey, data);
298+
expect(readCache(safeKey)).toEqual(data);
299+
});
300+
});

src/cache.ts

Lines changed: 49 additions & 7 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,33 @@ 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+
// Reject keys containing path separators or absolute-path markers
81+
// before any filesystem operation (defence-in-depth for static analysers).
82+
if (key.includes("/") || key.includes("\\") || isAbsolute(key)) return null;
83+
const base = resolve(getCacheDir());
84+
const target = resolve(base, key);
85+
const rel = relative(base, target);
86+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return null;
87+
// Fix: open the file once and use fstatSync on the same fd to avoid a
88+
// TOCTOU race condition between the age check and the read.
89+
// O_NOFOLLOW prevents symlink hijacking in world-writable directories
90+
// (CWE-377). On Windows the constant is undefined so we fall back to 0.
91+
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
92+
let fd: number | null = null;
7193
try {
72-
const stat = statSync(filePath);
94+
fd = openSync(target, constants.O_RDONLY | O_NOFOLLOW);
95+
const stat = fstatSync(fd);
96+
// Verify the descriptor points to a regular file — prevents reading from
97+
// FIFOs or device files that could block or expose unintended data (CWE-377).
98+
if (!stat.isFile()) return null;
7399
const ageMs = Date.now() - stat.mtimeMs;
74100
if (ageMs > CACHE_TTL_MS) return null;
75-
const raw = readFileSync(filePath, "utf8");
101+
const raw = readFileSync(fd, "utf8");
76102
return JSON.parse(raw) as T;
77103
} catch {
78104
return null;
105+
} finally {
106+
if (fd !== null) closeSync(fd);
79107
}
80108
}
81109

@@ -86,10 +114,24 @@ export function readCache<T>(key: string): T | null {
86114
* best-effort and must never crash the CLI.
87115
*/
88116
export function writeCache<T>(key: string, data: T): void {
117+
// Fix: write to a randomly-named temp file first, then rename atomically to
118+
// the target path. This avoids symlink/race attacks on the cache directory
119+
// and ensures readers never observe a partially-written file.
89120
try {
121+
// Reject keys containing path separators or absolute-path markers
122+
// before any filesystem operation (defence-in-depth for static analysers).
123+
if (key.includes("/") || key.includes("\\") || isAbsolute(key)) return;
90124
const dir = getCacheDir();
91-
mkdirSync(dir, { recursive: true });
92-
writeFileSync(join(dir, key), JSON.stringify(data), "utf8");
125+
const base = resolve(dir);
126+
const target = resolve(base, key);
127+
const rel = relative(base, target);
128+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) return;
129+
mkdirSync(dir, { recursive: true, mode: 0o700 });
130+
const tmpPath = `${target}.${randomBytes(6).toString("hex")}.tmp`;
131+
// mode 0o600: owner-only read/write, so the temp file is private even
132+
// when the parent directory is world-writable (CWE-377).
133+
writeFileSync(tmpPath, JSON.stringify(data), { encoding: "utf8", flag: "wx", mode: 0o600 });
134+
renameSync(tmpPath, target);
93135
} catch {
94136
// Best-effort: ignore write errors
95137
}

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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,40 @@ 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 — initial URL must come from github.com.
125+
try {
126+
const parsedUrl = new URL(url);
127+
if (parsedUrl.hostname !== "github.com") {
128+
throw new Error("Invalid host");
129+
}
130+
if (parsedUrl.protocol !== "https:") {
131+
throw new Error("Invalid protocol");
132+
}
133+
} catch {
134+
throw new Error("Invalid URL");
135+
}
136+
124137
if (debug) process.stdout.write(`[debug] downloading from ${url}\n`);
125138
const res = await fetch(url);
126139
if (debug)
127140
process.stdout.write(
128141
`[debug] fetch response: status=${res.status} ok=${res.ok} url=${res.url}\n`,
129142
);
143+
// Validate the final URL after potential redirects to guard against redirect-based SSRF.
144+
// GitHub release assets redirect from github.com to *.githubusercontent.com, so both
145+
// are allowed as redirect targets. res.url is empty for mocked responses (tests).
146+
if (res.url) {
147+
try {
148+
const finalUrl = new URL(res.url);
149+
const h = finalUrl.hostname;
150+
const isAllowedHost = h === "github.com" || h.endsWith(".githubusercontent.com");
151+
if (!isAllowedHost || finalUrl.protocol !== "https:") {
152+
throw new Error("Redirect blocked");
153+
}
154+
} catch {
155+
throw new Error("Redirect blocked: final URL does not match allowed domains");
156+
}
157+
}
130158
if (!res.ok) {
131159
throw new Error(`Download failed (${res.status}): ${url}`);
132160
}

0 commit comments

Comments
 (0)