Skip to content

Commit 0888726

Browse files
committed
Add security defense tests + eliminate system() command injection
TDD: 31 security tests covering shell injection prevention, SQLite authorizer (ATTACH/DETACH blocked), SQL injection via Cypher, path containment, and shell-free subprocess execution. - Add cbm_exec_no_shell() in compat_fs: fork+execvp (POSIX), _spawnvp (Windows) — executes commands without shell interpretation - Replace system() with cbm_exec_no_shell() for unzip extraction and version verification in update command — eliminates CodeQL command-line-injection alerts - CodeQL: switch to build-mode manual for 100% source file coverage - CodeQL gate: fix race condition between scan completion and alert API propagation (60s settle + double-check polling) - Dismiss TOCTOU in pass_envscan.c (benign read-only directory walk)
1 parent aa2b60b commit 0888726

File tree

10 files changed

+498
-25
lines changed

10 files changed

+498
-25
lines changed

.github/workflows/codeql.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,17 @@ jobs:
1414
steps:
1515
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
1616

17+
- name: Install build dependencies
18+
run: sudo apt-get update && sudo apt-get install -y zlib1g-dev
19+
1720
- name: Initialize CodeQL
1821
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
1922
with:
2023
languages: c-cpp
21-
build-mode: none
24+
build-mode: manual
25+
26+
- name: Build for CodeQL analysis
27+
run: scripts/build.sh
2228

2329
- name: Perform CodeQL Analysis
2430
uses: github/codeql-action/analyze@38697555549f1db7851b81482ff19f1fa5c4fedc # v4

.github/workflows/dry-run.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,30 @@ jobs:
120120
env:
121121
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
122122
run: |
123-
ALERTS=$(gh api repos/${{ github.repository }}/code-scanning/alerts?state=open --jq 'length' 2>/dev/null || echo "0")
124-
echo "Open code scanning alerts: $ALERTS"
123+
# Wait for GitHub to finish processing alert state changes.
124+
# There is a race between CodeQL marking the workflow as "completed"
125+
# and the alerts API reflecting new/closed alerts from that scan.
126+
echo "Waiting 60s for alert API to settle after CodeQL completion..."
127+
sleep 60
128+
129+
# Poll alerts twice with a gap to confirm the count is stable
130+
ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
131+
echo "Open alerts (check 1): $ALERTS1"
132+
sleep 15
133+
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
134+
echo "Open alerts (check 2): $ALERTS2"
135+
136+
# Use the higher count (conservative — if either check sees alerts, block)
137+
ALERTS=$ALERTS2
138+
if [ "$ALERTS1" -gt "$ALERTS2" ]; then
139+
ALERTS=$ALERTS1
140+
fi
141+
125142
if [ "$ALERTS" -gt 0 ]; then
126143
echo "BLOCKED: $ALERTS open code scanning alert(s) found."
127-
echo "Fix them before releasing: https://github.com/${{ github.repository }}/security/code-scanning"
144+
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
145+
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
146+
echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning"
128147
exit 1
129148
fi
130149
echo "=== CodeQL gate passed (0 open alerts) ==="

.github/workflows/release.yml

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,30 @@ jobs:
122122
env:
123123
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
124124
run: |
125-
ALERTS=$(gh api repos/${{ github.repository }}/code-scanning/alerts?state=open --jq 'length' 2>/dev/null || echo "0")
126-
echo "Open code scanning alerts: $ALERTS"
125+
# Wait for GitHub to finish processing alert state changes.
126+
# There is a race between CodeQL marking the workflow as "completed"
127+
# and the alerts API reflecting new/closed alerts from that scan.
128+
echo "Waiting 60s for alert API to settle after CodeQL completion..."
129+
sleep 60
130+
131+
# Poll alerts twice with a gap to confirm the count is stable
132+
ALERTS1=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
133+
echo "Open alerts (check 1): $ALERTS1"
134+
sleep 15
135+
ALERTS2=$(gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' --jq 'length' 2>/dev/null || echo "0")
136+
echo "Open alerts (check 2): $ALERTS2"
137+
138+
# Use the higher count (conservative — if either check sees alerts, block)
139+
ALERTS=$ALERTS2
140+
if [ "$ALERTS1" -gt "$ALERTS2" ]; then
141+
ALERTS=$ALERTS1
142+
fi
143+
127144
if [ "$ALERTS" -gt 0 ]; then
128145
echo "BLOCKED: $ALERTS open code scanning alert(s) found."
129-
echo "Fix them before releasing: https://github.com/${{ github.repository }}/security/code-scanning"
146+
gh api 'repos/${{ github.repository }}/code-scanning/alerts?state=open' \
147+
--jq '.[] | " #\(.number) [\(.rule.security_severity_level // .rule.severity)] \(.rule.id) — \(.most_recent_instance.location.path):\(.most_recent_instance.location.start_line)"' 2>/dev/null || true
148+
echo "Fix them: https://github.com/${{ github.repository }}/security/code-scanning"
130149
exit 1
131150
fi
132151
echo "=== CodeQL gate passed (0 open alerts on current commit) ==="

Makefile.cbm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,9 @@ TEST_MEM_SRCS = tests/test_mem.c
288288

289289
TEST_UI_SRCS = tests/test_ui.c
290290

291-
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_INTEGRATION_SRCS)
291+
TEST_SECURITY_SRCS = tests/test_security.c
292+
293+
ALL_TEST_SRCS = $(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_HTTPLINK_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_SECURITY_SRCS) $(TEST_INTEGRATION_SRCS)
292294

293295
# ── Build directories ────────────────────────────────────────────
294296

scripts/security-allowlist.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
# Any call to a listed function in a .c file under src/ that is NOT on this
55
# list causes the security audit (scripts/security-audit.sh) to fail.
66

7-
# ── Foundation: platform abstraction (defines cbm_popen wrapper) ───────────
7+
# ── Foundation: platform abstraction (defines cbm_popen wrapper + shell-free exec) ──
88
src/foundation/compat_fs.c:popen:cbm_popen wrapper definition (POSIX)
99
src/foundation/compat_fs.c:cbm_popen:cbm_popen function definition
10+
src/foundation/compat_fs.c:fork:cbm_exec_no_shell — fork+execvp for shell-free subprocess execution
11+
src/foundation/compat_fs.c:execvp:cbm_exec_no_shell — direct exec without shell interpretation
1012

1113
# ── CLI: update command (user-initiated, interactive) ──────────────────────
1214
src/cli/cli.c:system:curl download of release binary (update cmd)
13-
src/cli/cli.c:system:unzip extraction on Windows (update cmd)
14-
src/cli/cli.c:system:version verification after update (update cmd)
1515
src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd)
1616
src/cli/cli.c:popen:sha256 checksum computation via shasum
1717

src/cli/cli.c

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2810,15 +2810,9 @@ int cbm_cmd_update(int argc, char **argv) {
28102810
fclose(out);
28112811
free(bin_data);
28122812
} else {
2813-
/* Windows: unzip — validate paths before shell interpolation */
2814-
if (!cbm_validate_shell_arg(bin_dir) || !cbm_validate_shell_arg(tmp_archive)) {
2815-
fprintf(stderr, "error: path contains unsafe characters\n");
2816-
cbm_unlink(tmp_archive);
2817-
return 1;
2818-
}
2819-
snprintf(cmd, sizeof(cmd), "unzip -o -d '%s' '%s' 2>/dev/null", bin_dir, tmp_archive);
2820-
// NOLINTNEXTLINE(cert-env33-c) — intentional CLI subprocess for extraction
2821-
rc = system(cmd);
2813+
/* Zip extraction: exec unzip directly without shell interpretation */
2814+
const char *unzip_argv[] = {"unzip", "-o", "-d", bin_dir, tmp_archive, NULL};
2815+
rc = cbm_exec_no_shell(unzip_argv);
28222816
cbm_unlink(tmp_archive);
28232817
if (rc != 0) {
28242818
fprintf(stderr, "error: extraction failed\n");
@@ -2839,12 +2833,11 @@ int cbm_cmd_update(int argc, char **argv) {
28392833
int skill_count = cbm_install_skills(skills_dir, true, false);
28402834
printf("Updated %d skill(s).\n", skill_count);
28412835

2842-
/* Step 7: Verify new version */
2836+
/* Step 7: Verify new version (exec directly, no shell interpretation) */
28432837
printf("\nUpdate complete. Verifying:\n");
2844-
if (cbm_validate_shell_arg(bin_dest)) {
2845-
snprintf(cmd, sizeof(cmd), "'%s' --version", bin_dest);
2846-
// NOLINTNEXTLINE(cert-env33-c,clang-analyzer-optin.taint.GenericTaint)
2847-
(void)system(cmd);
2838+
{
2839+
const char *ver_argv[] = {bin_dest, "--version", NULL};
2840+
(void)cbm_exec_no_shell(ver_argv);
28482841
}
28492842

28502843
printf("\nAll project indexes were cleared. They will be rebuilt\n");

src/foundation/compat_fs.c

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,21 @@ int cbm_rmdir(const char *path) {
144144
return _rmdir(path);
145145
}
146146

147+
int cbm_exec_no_shell(const char *const *argv) {
148+
if (!argv || !argv[0]) {
149+
return -1;
150+
}
151+
return (int)_spawnvp(_P_WAIT, argv[0], argv);
152+
}
153+
147154
#else /* POSIX */
148155

149156
/* ── POSIX implementation ─────────────────────────────────────── */
150157

151158
#include <dirent.h>
152159
#include <errno.h>
153160
#include <sys/stat.h>
161+
#include <sys/wait.h>
154162
#include <unistd.h>
155163

156164
struct cbm_dir {
@@ -248,4 +256,30 @@ int cbm_rmdir(const char *path) {
248256
return rmdir(path);
249257
}
250258

259+
int cbm_exec_no_shell(const char *const *argv) {
260+
if (!argv || !argv[0]) {
261+
return -1;
262+
}
263+
pid_t pid = fork();
264+
if (pid < 0) {
265+
return -1;
266+
}
267+
if (pid == 0) {
268+
/* Child: exec directly — no shell interpretation */
269+
/* 127 = standard "command not found" exit code (POSIX convention) */
270+
enum { EXEC_NOT_FOUND = 127 };
271+
execvp(argv[0], (char *const *)argv);
272+
_exit(EXEC_NOT_FOUND);
273+
}
274+
/* Parent: wait for child */
275+
int status = 0;
276+
if (waitpid(pid, &status, 0) < 0) {
277+
return -1;
278+
}
279+
if (WIFEXITED(status)) {
280+
return WEXITSTATUS(status);
281+
}
282+
return -1; /* killed by signal */
283+
}
284+
251285
#endif /* _WIN32 */

src/foundation/compat_fs.h

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,4 +50,10 @@ int cbm_unlink(const char *path);
5050
/* Delete an empty directory. Returns 0 on success. */
5151
int cbm_rmdir(const char *path);
5252

53+
/* Execute a command without shell interpretation.
54+
* argv is a NULL-terminated array: {"cmd", "arg1", "arg2", NULL}.
55+
* Returns the process exit code, or -1 on fork/exec failure.
56+
* POSIX: fork() + execvp(). Windows: _spawnvp(). */
57+
int cbm_exec_no_shell(const char *const *argv);
58+
5359
#endif /* CBM_COMPAT_FS_H */

tests/test_main.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ extern void suite_worker_pool(void);
4848
extern void suite_parallel(void);
4949
extern void suite_mem(void);
5050
extern void suite_ui(void);
51+
extern void suite_security(void);
5152
extern void suite_integration(void);
5253

5354
int main(void) {
@@ -132,6 +133,9 @@ int main(void) {
132133
/* UI (config, embedded assets, layout) */
133134
RUN_SUITE(ui);
134135

136+
/* Security defenses */
137+
RUN_SUITE(security);
138+
135139
/* Integration (end-to-end) */
136140
RUN_SUITE(integration);
137141

0 commit comments

Comments
 (0)