-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgitlab-entrypoint.sh
More file actions
executable file
·245 lines (224 loc) · 11.1 KB
/
gitlab-entrypoint.sh
File metadata and controls
executable file
·245 lines (224 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
#!/usr/bin/env bash
# gitlab-entrypoint.sh — GitLab CI wrapper for gha-exploit-guard
#
# Maps GitLab CI variables to scanner CLI flags and applies security
# hardening (path-traversal prevention, symlink protection).
#
# Environment variables (set via GitLab CI/CD):
# EXPLOIT_GUARD_TARGET_DIR Directory to scan, relative to project root (default: .)
# EXPLOIT_GUARD_FAIL_ON_FINDINGS Fail job when findings exist (default: false)
# EXPLOIT_GUARD_EXCLUDE Comma-separated check IDs to skip (e.g. EG001,EG035)
# EXPLOIT_GUARD_WARN_ONLY Report findings without failing (default: false)
# EXPLOIT_GUARD_SARIF SARIF output file path (default: exploit-guards.sarif)
set -euo pipefail
umask 0077
export LC_ALL=C
export PYTHONUTF8=1
# ── Validate TMPDIR ──────────────────────────────────────────────────
if [ -n "${TMPDIR:-}" ]; then
_real_tmp="$(cd "$TMPDIR" 2>/dev/null && pwd -P)" || _real_tmp=""
case "$_real_tmp" in
/tmp|/tmp/*|/var/tmp|/var/tmp/*|/private/tmp|/private/tmp/*) ;;
*)
echo "WARNING: TMPDIR='$TMPDIR' is not a standard temp directory. Overriding to /tmp." >&2
export TMPDIR=/tmp
;;
esac
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# ── Require O_NOFOLLOW support ─────────────────────────────────────────
# Without O_NOFOLLOW, symlink-hardening logic is ineffective and TOCTOU
# attacks become possible. Refuse to run on platforms that lack it.
if ! PYTHONHOME= PYTHONSTARTUP= PYTHONPATH= PYTHONDONTWRITEBYTECODE=1 python3 -I -P -c "import os; assert hasattr(os, 'O_NOFOLLOW'), 'O_NOFOLLOW unavailable'" 2>/dev/null; then
echo "ERROR: This platform does not support O_NOFOLLOW. Symlink hardening cannot be enforced." >&2
exit 2
fi
# ── Defaults ─────────────────────────────────────────────────────────
PROJECT_DIR="${CI_PROJECT_DIR:-$(pwd)}"
TARGET_DIR="${EXPLOIT_GUARD_TARGET_DIR:-.}"
FAIL_ON_FINDINGS="${EXPLOIT_GUARD_FAIL_ON_FINDINGS:-false}"
EXCLUDE="${EXPLOIT_GUARD_EXCLUDE:-}"
WARN_ONLY="${EXPLOIT_GUARD_WARN_ONLY:-false}"
SARIF_FILE="${EXPLOIT_GUARD_SARIF:-exploit-guards.sarif}"
# ── Mutual-exclusion check ────────────────────────────────────────────
_is_truthy() {
case "${1:-}" in [Tt][Rr][Uu][Ee]|1|[Yy][Ee][Ss]|[Oo][Nn]) return 0 ;; *) return 1 ;; esac
}
if _is_truthy "$FAIL_ON_FINDINGS" && _is_truthy "$WARN_ONLY"; then
echo "ERROR: EXPLOIT_GUARD_FAIL_ON_FINDINGS and EXPLOIT_GUARD_WARN_ONLY are both enabled. These conflict — warn-only silently overrides fail-on-findings." >&2
exit 2
fi
# ── EXCLUDE format validation ──────────────────────────────────────────
if [ -n "$EXCLUDE" ]; then
if ! printf '%s' "$EXCLUDE" | grep -Eq '^EG[0-9]{3}(,EG[0-9]{3})*$'; then
echo "ERROR: EXPLOIT_GUARD_EXCLUDE has invalid format (expected EG001,EG002,...)" >&2
exit 2
fi
fi
# ── Temp file cleanup trap ─────────────────────────────────────────────
_tmp_files=()
_entrypoint_cleanup() {
local f
for f in "${_tmp_files[@]+${_tmp_files[@]}}"; do
[ -n "$f" ] && rm -f "$f"
done
}
trap _entrypoint_cleanup EXIT INT TERM
# ── Path-traversal prevention ────────────────────────────────────────
# Resolve target-dir and verify it stays within the project root.
resolved="$(PYTHONHOME= PYTHONSTARTUP= PYTHONPATH= PYTHONDONTWRITEBYTECODE=1 python3 -I -P -c "
import os, sys
workspace = sys.argv[1]
target = sys.argv[2]
candidate = os.path.realpath(os.path.join(workspace, target))
norm_ws = os.path.realpath(workspace)
if not (candidate == norm_ws or candidate.startswith(norm_ws + os.sep)):
sys.exit(f'ERROR: target-dir resolves outside project root: {candidate!r}')
print(candidate)
" "$PROJECT_DIR" "$TARGET_DIR")"
# ── SARIF path-traversal prevention ────────────────────────────────
# Resolve SARIF output path and verify it (and all parent directories)
# stay within the project root. Also reject symlinks in any path
# component to close the TOCTOU window between this check and the
# eventual write.
SARIF_FILE="$(PYTHONHOME= PYTHONSTARTUP= PYTHONPATH= PYTHONDONTWRITEBYTECODE=1 python3 -I -P -c "
import os, sys
workspace = sys.argv[1]
sarif = sys.argv[2]
candidate = os.path.realpath(os.path.join(workspace, sarif))
norm_ws = os.path.realpath(workspace)
if not (candidate == norm_ws or candidate.startswith(norm_ws + os.sep)):
sys.exit(f'ERROR: sarif path resolves outside project root: {candidate!r}')
# Walk each component from sarif parent up to workspace to reject symlinks
current = os.path.dirname(os.path.join(workspace, sarif))
while True:
real_current = os.path.realpath(current)
if real_current != os.path.abspath(current):
sys.exit(f'ERROR: sarif path contains symlink in intermediate directory: {current!r}')
if real_current == norm_ws or real_current == '/':
break
current = os.path.dirname(current)
# Also reject if the SARIF file already exists as a symlink
if os.path.lexists(candidate) and os.path.islink(candidate):
sys.exit(f'ERROR: sarif output path is an existing symlink: {candidate!r}')
print(candidate)
" "$PROJECT_DIR" "$SARIF_FILE")"
# ── Build CLI arguments ──────────────────────────────────────────────
cli_args=("$resolved" "--no-color" "--sarif" "$SARIF_FILE")
if [ -n "$EXCLUDE" ]; then
cli_args+=("--exclude" "$EXCLUDE")
fi
if _is_truthy "$WARN_ONLY"; then
cli_args+=("--warn-only")
fi
# ── Run scanner ──────────────────────────────────────────────────────
txt_tmp="$(mktemp "${TMPDIR:-/tmp}/.exploit-guards.txt.XXXXXX")"
err_tmp="$(mktemp "${TMPDIR:-/tmp}/.exploit-guards.err.XXXXXX")"
_tmp_files+=("$txt_tmp" "$err_tmp")
set +e
bash "$SCRIPT_DIR/gha-exploit-guard.sh" "${cli_args[@]}" > "$txt_tmp" 2>"$err_tmp"
ec=$?
set -e
# Print stderr to job log (visible to user) but keep it out of the text artifact.
# Strip ANSI escape sequences to prevent terminal injection in CI log viewers.
# Truncate stderr to 1MB to prevent memory exhaustion in sed
_max_err_bytes=1048576
_tmp_files+=("$err_tmp.trunc")
if [ -s "$err_tmp" ]; then
_err_size=$(wc -c < "$err_tmp" 2>/dev/null) || _err_size=0
if [ "$_err_size" -gt "$_max_err_bytes" ]; then
(dd if="$err_tmp" of="$err_tmp.trunc" bs="$_max_err_bytes" count=1 2>/dev/null && mv -f "$err_tmp.trunc" "$err_tmp") || {
rm -f "$err_tmp.trunc" 2>/dev/null || true
echo "WARNING: Failed to truncate stderr, continuing with full output" >&2
}
fi
fi
if [ -s "$err_tmp" ]; then
head -c "$_max_err_bytes" "$err_tmp" | sed -e 's/\x1b\[[0-9;]*[a-zA-Z]//g' -e 's/\x1b\][^\x07]*\x07//g' -e 's/\x1b[PX^_][^\x1b]*\x1b\\//g' -e 's/\x9b[0-9;]*[a-zA-Z]//g' | tr -d '\000-\010\013\014\016-\037' >&2
fi
# ── Verify working directory is project root ──────────────────────────
if [ "$(pwd -P)" != "$(cd "$PROJECT_DIR" && pwd -P)" ]; then
echo "ERROR: Working directory differs from PROJECT_DIR. Refusing to write output files." >&2
exit 2
fi
# ── Symlink-hardened output copy ─────────────────────────────────────
# Use O_NOFOLLOW to prevent symlink-based attacks on the output path.
# Use mktemp for the destination to avoid predictable-path hardlink attacks,
# then atomically rename into place.
PYTHONHOME= PYTHONSTARTUP= PYTHONPATH= PYTHONDONTWRITEBYTECODE=1 python3 -I -P - "$txt_tmp" exploit-guards.txt << 'PYEOF'
import os, sys, tempfile
src, final_dst = sys.argv[1], sys.argv[2]
# Write to a temp file first, then rename (atomic on same filesystem)
dst_dir = os.path.dirname(os.path.abspath(final_dst)) or '.'
tmp_fd, tmp_path = tempfile.mkstemp(dir=dst_dir, prefix='.eg-txt-', suffix='.tmp')
try:
src_flags = os.O_RDONLY | os.O_NOFOLLOW
with os.fdopen(os.open(src, src_flags), "rb") as in_f:
os.write(tmp_fd, in_f.read())
os.fchmod(tmp_fd, 0o644)
os.close(tmp_fd)
tmp_fd = -1
os.rename(tmp_path, final_dst)
except BaseException:
if tmp_fd >= 0:
os.close(tmp_fd)
try:
os.unlink(tmp_path)
except OSError:
pass
raise
os.unlink(src)
PYEOF
# ── Validate SARIF output ───────────────────────────────────────────
_MAX_SARIF_BYTES=52428800 # 50 MB
if [ -s "$SARIF_FILE" ]; then
_sarif_size=$(PYTHONHOME= PYTHONSTARTUP= PYTHONPATH= PYTHONDONTWRITEBYTECODE=1 python3 -I -P -c "
import os, sys
try:
fd = os.open(sys.argv[1], os.O_RDONLY | os.O_NOFOLLOW)
size = os.fstat(fd).st_size
os.close(fd)
print(size)
except OSError as e:
sys.exit(f'ERROR: Cannot stat {sys.argv[1]}: {e}')
" "$SARIF_FILE")
if [ "$_sarif_size" -gt "$_MAX_SARIF_BYTES" ]; then
echo "ERROR: SARIF file is ${_sarif_size} bytes (limit: ${_MAX_SARIF_BYTES}). Refusing to parse." >&2
exit 2
fi
PYTHONHOME= PYTHONSTARTUP= PYTHONPATH= PYTHONDONTWRITEBYTECODE=1 python3 -I -P -c "
import json, os, sys
_flags = os.O_RDONLY | os.O_NOFOLLOW
try:
_fd = os.open(sys.argv[1], _flags)
except OSError as _e:
sys.exit(f'ERROR: Cannot open {sys.argv[1]}: {_e}')
with os.fdopen(_fd, 'r', encoding='utf-8') as _f:
data = json.load(_f)
if not isinstance(data.get('runs'), list):
sys.exit('Invalid SARIF: missing runs[]')
n = sum(len(r.get('results', [])) for r in data['runs'])
rules = data['runs'][0]['tool']['driver'].get('rules', []) if data['runs'] else []
if n > 0 and not rules:
print('WARNING: SARIF has results but no rules[] definitions', file=sys.stderr)
print(f'exploit-guards produced {n} finding(s)' if n else 'exploit-guards completed with no findings')
" "$SARIF_FILE"
else
echo "WARNING: $SARIF_FILE was not generated. Scanner did not produce output." >&2
if [ "$ec" -le 1 ]; then
echo "WARNING: Scanner exited $ec but $SARIF_FILE was not generated or is empty." >&2
fi
fi
# ── Exit code handling ───────────────────────────────────────────────
if [ "$ec" -gt 1 ]; then
echo "ERROR: exploit-guards failed before producing trustworthy results (exit code $ec)." >&2
exit "$ec"
fi
if [ "$ec" -eq 1 ]; then
if _is_truthy "$FAIL_ON_FINDINGS"; then
echo "ERROR: exploit-guards reported findings and fail-on-findings is enabled." >&2
exit 1
fi
echo "exploit-guards reported findings (exit code 1) but fail-on-findings is not enabled."
fi
exit 0