@aldegad/safedeps 2.1.1 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +273 -463
- package/README.ko.md +76 -12
- package/README.md +107 -38
- package/ROADMAP.md +123 -84
- package/SECURITY.md +45 -0
- package/SKILL.md +86 -143
- package/bin/safedeps +419 -52
- package/lib/gates/audit.sh +36 -0
- package/lib/gates/doctor.sh +212 -0
- package/lib/gates/hooks.sh +131 -0
- package/lib/gates/repo-profile.sh +60 -0
- package/lib/gates/scan.sh +94 -0
- package/lib/gates/templates/gitleaks.private.toml.tmpl +45 -0
- package/lib/gates/templates/gitleaks.toml.tmpl +43 -0
- package/lib/gates/templates/pre-commit.tmpl +49 -0
- package/lib/ledger/ledger.sh +94 -16
- package/lib/npm/closure.sh +115 -0
- package/lib/providers/providers.sh +248 -26
- package/package.json +2 -1
- package/scripts/install/install-safedeps-hooks.mjs +65 -23
- package/scripts/release-gates.sh +252 -0
- package/scripts/safedeps-post-verify.sh +185 -15
- package/scripts/safedeps-pre-guard.sh +309 -39
- package/scripts/test/e2e.sh +228 -4
- package/scripts/test/fixture-provider.mjs +21 -0
- package/scripts/test/smoke.sh +212 -10
|
@@ -36,8 +36,26 @@ SAFEDEPS_MANIFEST_FILES=(
|
|
|
36
36
|
umask 077
|
|
37
37
|
mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
|
|
38
38
|
|
|
39
|
+
# Observable record of any gate bypass / unavailability (AGENTS.md: no silent fallback —
|
|
40
|
+
# every bypass must be observable and logged).
|
|
41
|
+
log_advisory() {
|
|
42
|
+
printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "${GUARD_DIR}/advisory.log" 2>/dev/null || true
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
if ! command -v jq >/dev/null 2>&1; then
|
|
40
|
-
|
|
46
|
+
# jq is required to parse the hook payload. Without it we cannot read the exact
|
|
47
|
+
# command, so do a best-effort fail-closed: read the raw payload and, if it
|
|
48
|
+
# looks like a dependency install, DENY (an install we cannot verify must not
|
|
49
|
+
# proceed). Non-install commands are allowed — jq absence must not block `ls`.
|
|
50
|
+
# Either branch is recorded in advisory.log; never a silent skip.
|
|
51
|
+
raw_input=$(cat)
|
|
52
|
+
log_advisory "pre-guard: jq missing — gate cannot parse the payload."
|
|
53
|
+
if printf '%s' "${raw_input}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
|
|
54
|
+
log_advisory "pre-guard DENY: jq missing on a likely dependency-install command — fail-closed."
|
|
55
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"safedeps: jq is required to gate dependency installs and is not installed — install blocked fail-closed. Install jq, then retry."}}\n'
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
echo "safedeps: jq is not installed — install gate disabled (non-install commands still allowed); logged to advisory.log." >&2
|
|
41
59
|
exit 0
|
|
42
60
|
fi
|
|
43
61
|
|
|
@@ -48,8 +66,10 @@ acquire_state_lock() {
|
|
|
48
66
|
# Detect stale locks left by SIGKILL/OOM (V-005)
|
|
49
67
|
if [[ -d "${STATE_LOCK_DIR}" ]]; then
|
|
50
68
|
local lock_mtime=""
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
# GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
|
|
70
|
+
# means --file-system and would not yield an mtime.
|
|
71
|
+
if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
|
|
72
|
+
lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
|
|
53
73
|
local now
|
|
54
74
|
now=$(date +%s)
|
|
55
75
|
if [[ $(( now - lock_mtime )) -gt 60 ]]; then
|
|
@@ -61,8 +81,11 @@ acquire_state_lock() {
|
|
|
61
81
|
fi
|
|
62
82
|
|
|
63
83
|
attempts=$((attempts + 1))
|
|
64
|
-
if [[ ${attempts} -ge 100 ]]; then
|
|
65
|
-
|
|
84
|
+
if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
|
|
85
|
+
# acquire_state_lock is only reached for install candidates, so failing to
|
|
86
|
+
# serialize/snapshot means this install cannot be gated — fail CLOSED (deny).
|
|
87
|
+
log_advisory "pre-guard DENY: state lock unavailable for an install command — fail-closed."
|
|
88
|
+
jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"safedeps: could not acquire the state lock (another safedeps run may be active). Install blocked fail-closed — retry in a moment."}}'
|
|
66
89
|
exit 0
|
|
67
90
|
fi
|
|
68
91
|
sleep 0.1
|
|
@@ -76,10 +99,16 @@ release_state_lock() {
|
|
|
76
99
|
write_state_file() {
|
|
77
100
|
local target_path="$1"
|
|
78
101
|
local value="$2"
|
|
79
|
-
local
|
|
80
|
-
|
|
102
|
+
local target_dir
|
|
103
|
+
local target_base
|
|
104
|
+
local temp_path
|
|
105
|
+
|
|
106
|
+
target_dir=$(dirname "${target_path}")
|
|
107
|
+
target_base=$(basename "${target_path}")
|
|
108
|
+
mkdir -p "${target_dir}" || return 1
|
|
109
|
+
temp_path=$(mktemp "${target_dir}/.${target_base}.XXXXXX") || return 1
|
|
81
110
|
printf '%s\n' "${value}" > "${temp_path}"
|
|
82
|
-
mv "${temp_path}" "${target_path}"
|
|
111
|
+
mv -f "${temp_path}" "${target_path}"
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
compute_dir_hash() {
|
|
@@ -99,10 +128,13 @@ command_is_dependency_install() {
|
|
|
99
128
|
local scan_command
|
|
100
129
|
local install_pattern
|
|
101
130
|
|
|
102
|
-
|
|
103
|
-
install_pattern='(^|[;&|]+[[:space:]]*)((npm[[:space:]]+(install|i|add|update|up|upgrade))|npx[[:space:]]|pnpm[[:space:]]+(add|install|update|up|dlx)|yarn[[:space:]]+(add|install|upgrade|dlx)|((python3?|py)[[:space:]]+-m[[:space:]]+pip|pip3?)[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn[[:space:]]+dependency:get|dotnet[[:space:]]+add[[:space:]]+package)([[:space:]]|$)'
|
|
131
|
+
install_pattern='(^|[;&|]+[[:space:]]*)((npm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(install|i|add|ci|update|up|upgrade))|npx[[:space:]]|pnpm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(add|install|update|up|dlx)|yarn([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(add|install|upgrade|dlx)|((python3?|py)[[:space:]]+-m[[:space:]]+pip|pip3?)[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn[[:space:]]+dependency:get|dotnet[[:space:]]+add[[:space:]]+package)([[:space:]]|$)'
|
|
104
132
|
|
|
105
|
-
|
|
133
|
+
while IFS= read -r scan_command; do
|
|
134
|
+
scan_command=$(command_scan_text "${scan_command}")
|
|
135
|
+
echo "${scan_command}" | grep -qEi "${install_pattern}" && return 0
|
|
136
|
+
done < <(command_candidate_texts "${command}")
|
|
137
|
+
return 1
|
|
106
138
|
}
|
|
107
139
|
|
|
108
140
|
command_hides_dependency_install() {
|
|
@@ -113,7 +145,7 @@ command_hides_dependency_install() {
|
|
|
113
145
|
manager_pattern='(npm|npx|pnpm|yarn|pip3?|python3?[[:space:]]+-m[[:space:]]+pip|poetry|uv|pipenv|cargo|go|gem|bundle|mvn|dotnet)'
|
|
114
146
|
verb_pattern='(install|i|add|update|up|upgrade|dlx|get|dependency:get|package)'
|
|
115
147
|
|
|
116
|
-
echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(
|
|
148
|
+
echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(|`|(^|[[:space:];|&])(bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]])' && \
|
|
117
149
|
echo "${command}" | grep -qEi "${manager_pattern}.*${verb_pattern}"
|
|
118
150
|
}
|
|
119
151
|
|
|
@@ -154,6 +186,93 @@ command_scan_text() {
|
|
|
154
186
|
printf '%s' "${output}"
|
|
155
187
|
}
|
|
156
188
|
|
|
189
|
+
normalize_install_text() {
|
|
190
|
+
local text="$1"
|
|
191
|
+
|
|
192
|
+
for _ in 1 2 3; do
|
|
193
|
+
text=$(printf '%s' "${text}" | sed -E \
|
|
194
|
+
-e 's#(^|[[:space:];|&])(/[^[:space:];|&]+/)(npm|npx|pnpm|yarn|pip3?|python3?|py|poetry|uv|pipenv|cargo|go|gem|bundle|mvn|dotnet)([[:space:];|&]|$)#\1\3\4#g' \
|
|
195
|
+
-e 's#(^|[;&|][[:space:]]*)(env[[:space:]]+([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]+[[:space:]]+)*|command[[:space:]]+)#\1#g')
|
|
196
|
+
done
|
|
197
|
+
printf '%s' "${text}"
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
strip_heredoc_bodies() {
|
|
201
|
+
local input="$1"
|
|
202
|
+
local line
|
|
203
|
+
local delimiter=""
|
|
204
|
+
local heredoc_re="<<-?[[:space:]]*[\"']?([A-Za-z0-9_][A-Za-z0-9_.-]*)[\"']?"
|
|
205
|
+
|
|
206
|
+
while IFS= read -r line || [[ -n "${line}" ]]; do
|
|
207
|
+
if [[ -n "${delimiter}" ]]; then
|
|
208
|
+
if [[ "${line}" == "${delimiter}" ]]; then
|
|
209
|
+
delimiter=""
|
|
210
|
+
fi
|
|
211
|
+
continue
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
if [[ "${line}" =~ ${heredoc_re} ]]; then
|
|
215
|
+
delimiter="${BASH_REMATCH[1]}"
|
|
216
|
+
fi
|
|
217
|
+
printf '%s\n' "${line}"
|
|
218
|
+
done <<< "${input}"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
extract_shell_c_payloads() {
|
|
222
|
+
local rest="$1"
|
|
223
|
+
|
|
224
|
+
while [[ "${rest}" =~ (bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]]+\"([^\"]*)\" ]]; do
|
|
225
|
+
printf '%s\n' "${BASH_REMATCH[2]}"
|
|
226
|
+
rest="${rest#*"${BASH_REMATCH[0]}"}"
|
|
227
|
+
done
|
|
228
|
+
|
|
229
|
+
rest="$1"
|
|
230
|
+
while [[ "${rest}" =~ (bash|sh|zsh)[[:space:]]+-[A-Za-z]*c[[:space:]]+\'([^\']*)\' ]]; do
|
|
231
|
+
printf '%s\n' "${BASH_REMATCH[2]}"
|
|
232
|
+
rest="${rest#*"${BASH_REMATCH[0]}"}"
|
|
233
|
+
done
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
command_candidate_texts() {
|
|
237
|
+
local command="$1"
|
|
238
|
+
local payload
|
|
239
|
+
|
|
240
|
+
command=$(strip_heredoc_bodies "${command}")
|
|
241
|
+
|
|
242
|
+
normalize_install_text "${command}"
|
|
243
|
+
printf '\n'
|
|
244
|
+
while IFS= read -r payload; do
|
|
245
|
+
[[ -z "${payload}" ]] && continue
|
|
246
|
+
normalize_install_text "${payload}"
|
|
247
|
+
printf '\n'
|
|
248
|
+
done < <(extract_shell_c_payloads "${command}")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
command_is_injectable_npm_install() {
|
|
252
|
+
local command="$1"
|
|
253
|
+
local scan_command
|
|
254
|
+
local npm_install_pattern
|
|
255
|
+
|
|
256
|
+
npm_install_pattern='(^|[;&|]+[[:space:]]*)npm([[:space:]]+--?[a-zA-Z0-9_-]+([=[:space:]][^[:space:]]+)?)?[[:space:]]+(install|i|add|ci|update|up|upgrade)([[:space:]]|$)'
|
|
257
|
+
|
|
258
|
+
while IFS= read -r scan_command; do
|
|
259
|
+
scan_command=$(command_scan_text "${scan_command}")
|
|
260
|
+
echo "${scan_command}" | grep -qEi "${npm_install_pattern}" && return 0
|
|
261
|
+
done < <(command_candidate_texts "${command}")
|
|
262
|
+
return 1
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
command_has_ignore_scripts_flag() {
|
|
266
|
+
local command="$1"
|
|
267
|
+
local scan_command
|
|
268
|
+
|
|
269
|
+
while IFS= read -r scan_command; do
|
|
270
|
+
scan_command=$(command_scan_text "${scan_command}")
|
|
271
|
+
echo "${scan_command}" | grep -qEi -- '(^|[[:space:]])--ignore-scripts([=[:space:]]|$)' && return 0
|
|
272
|
+
done < <(command_candidate_texts "${command}")
|
|
273
|
+
return 1
|
|
274
|
+
}
|
|
275
|
+
|
|
157
276
|
snapshot_project_file() {
|
|
158
277
|
local relative_file="$1"
|
|
159
278
|
local category="${2:-manifest}"
|
|
@@ -275,10 +394,24 @@ cat > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json" << META_EOF
|
|
|
275
394
|
"timestamp": ${TIMESTAMP},
|
|
276
395
|
"project_dir": $(printf '%s' "${PROJECT_DIR}" | jq -Rs .),
|
|
277
396
|
"command": $(printf '%s' "${COMMAND}" | jq -Rs .),
|
|
397
|
+
"ignore_scripts_injected": false,
|
|
278
398
|
"lock_files_found": ${SNAPSHOTTED}
|
|
279
399
|
}
|
|
280
400
|
META_EOF
|
|
281
401
|
|
|
402
|
+
mark_ignore_scripts_injected() {
|
|
403
|
+
local meta_file="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json"
|
|
404
|
+
local temp_file
|
|
405
|
+
|
|
406
|
+
[[ -f "${meta_file}" ]] || return 0
|
|
407
|
+
temp_file=$(mktemp "${SNAPSHOT_DIR}/.${SNAPSHOT_ID}_meta.XXXXXX") || return 0
|
|
408
|
+
if jq '.ignore_scripts_injected = true' "${meta_file}" > "${temp_file}"; then
|
|
409
|
+
mv -f "${temp_file}" "${meta_file}"
|
|
410
|
+
else
|
|
411
|
+
rm -f "${temp_file}"
|
|
412
|
+
fi
|
|
413
|
+
}
|
|
414
|
+
|
|
282
415
|
# --- Pre-flight security checks on the command itself ---
|
|
283
416
|
|
|
284
417
|
SUSPICIOUS=false
|
|
@@ -320,46 +453,147 @@ fi
|
|
|
320
453
|
|
|
321
454
|
# --- Phase 2 advisory gate — ledger enforcement -------------------------------
|
|
322
455
|
# For commands that name specific packages, require an entry in the approved-
|
|
323
|
-
# spec ledger. Miss/expired → block with a structured message that names
|
|
324
|
-
#
|
|
456
|
+
# spec ledger. Miss/expired → block with a structured message that names a
|
|
457
|
+
# runnable `safedeps check` command the caller (agent or human) should run
|
|
458
|
+
# next — PATH command when present, else an absolute path, so the self-heal
|
|
459
|
+
# loop never dead-ends on a missing PATH symlink.
|
|
325
460
|
#
|
|
326
461
|
# Conservative: only block when at least one pkg@spec token is parseable. Bare
|
|
327
462
|
# `npm install` (lockfile install) falls through to the v1 reorg checks.
|
|
328
463
|
|
|
329
|
-
SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
|
|
464
|
+
SAFEDEPS_LEDGER_LIB="${SAFEDEPS_LEDGER_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh}"
|
|
330
465
|
SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
|
|
331
466
|
|
|
332
467
|
guard_detect_ecosystem() {
|
|
333
468
|
local cmd="$1"
|
|
334
469
|
local scan_cmd
|
|
335
470
|
|
|
336
|
-
scan_cmd
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
471
|
+
while IFS= read -r scan_cmd; do
|
|
472
|
+
scan_cmd=$(command_scan_text "${scan_cmd}")
|
|
473
|
+
if echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(npm|pnpm|yarn|npx)([[:space:]]|$)'; then
|
|
474
|
+
printf 'npm'
|
|
475
|
+
return 0
|
|
476
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(pip3?|poetry|uv|pipenv|((python3?|py)[[:space:]]+-m[[:space:]]+pip))([[:space:]]|$)'; then
|
|
477
|
+
printf 'pypi'
|
|
478
|
+
return 0
|
|
479
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)cargo([[:space:]]|$)'; then
|
|
480
|
+
printf 'crates.io'
|
|
481
|
+
return 0
|
|
482
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)go([[:space:]]|$)'; then
|
|
483
|
+
printf 'go'
|
|
484
|
+
return 0
|
|
485
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(gem|bundle)([[:space:]]|$)'; then
|
|
486
|
+
printf 'rubygems'
|
|
487
|
+
return 0
|
|
488
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)mvn([[:space:]]|$)'; then
|
|
489
|
+
printf 'maven'
|
|
490
|
+
return 0
|
|
491
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)dotnet([[:space:]]|$)'; then
|
|
492
|
+
printf 'nuget'
|
|
493
|
+
return 0
|
|
494
|
+
fi
|
|
495
|
+
done < <(command_candidate_texts "${cmd}")
|
|
496
|
+
printf ''
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
guard_runner_operands() {
|
|
500
|
+
# Runner forms (`npx`, `pnpm dlx`, `yarn dlx`) EXECUTE a package; tokens after
|
|
501
|
+
# the executed package are arguments to that program, NOT package specs. Emit
|
|
502
|
+
# only the spec-bearing operands: any `-p/--package <pkg>` value plus the first
|
|
503
|
+
# bare token (the executed package). This stops an argument such as an email
|
|
504
|
+
# (`dev1@block-s.io`) or a secret value passed to `npx wrangler ...` from being
|
|
505
|
+
# misread as a `pkg@spec` install.
|
|
506
|
+
local scan="$1"
|
|
507
|
+
local after want_value tok
|
|
508
|
+
after=$(printf '%s' "${scan}" | grep -oiE '(npx|dlx)[[:space:]].*' | head -n1 || true)
|
|
509
|
+
after="${after#* }" # drop the runner keyword, keep its operands
|
|
510
|
+
[[ -z "${after}" ]] && return 0
|
|
511
|
+
|
|
512
|
+
want_value=false
|
|
513
|
+
for tok in ${after}; do
|
|
514
|
+
if [[ "${want_value}" == true ]]; then
|
|
515
|
+
printf '%s\n' "${tok}"
|
|
516
|
+
want_value=false
|
|
517
|
+
continue
|
|
518
|
+
fi
|
|
519
|
+
case "${tok}" in
|
|
520
|
+
-p|--package) want_value=true ;;
|
|
521
|
+
--package=*) printf '%s\n' "${tok#--package=}" ;;
|
|
522
|
+
-*) : ;; # other flag (e.g. -y/--yes), skip
|
|
523
|
+
*)
|
|
524
|
+
printf '%s\n' "${tok}" # executed package; rest are program args
|
|
525
|
+
break
|
|
526
|
+
;;
|
|
527
|
+
esac
|
|
528
|
+
done
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
guard_extract_flagged_specs() {
|
|
532
|
+
awk '
|
|
533
|
+
{
|
|
534
|
+
for (i = 1; i <= NF; i++) {
|
|
535
|
+
if ($i ~ /^[A-Za-z][A-Za-z0-9._-]*==[A-Za-z0-9][A-Za-z0-9._+!~-]*$/) {
|
|
536
|
+
split($i, parts, "==")
|
|
537
|
+
print parts[1] "\t" parts[2]
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if ($i == "gem" && $(i + 1) == "install") {
|
|
541
|
+
pkg = $(i + 2)
|
|
542
|
+
for (j = i + 3; j <= NF; j++) {
|
|
543
|
+
if (($j == "-v" || $j == "--version") && $(j + 1) != "") print pkg "\t" $(j + 1)
|
|
544
|
+
if ($j ~ /^--version=/) { sub(/^--version=/, "", $j); print pkg "\t" $j }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
if ($i == "cargo" && $(i + 1) == "add") {
|
|
549
|
+
pkg = $(i + 2)
|
|
550
|
+
for (j = i + 3; j <= NF; j++) {
|
|
551
|
+
if (($j == "--vers" || $j == "--version") && $(j + 1) != "") print pkg "\t" $(j + 1)
|
|
552
|
+
if ($j ~ /^--(vers|version)=/) { sub(/^--(vers|version)=/, "", $j); print pkg "\t" $j }
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if ($i == "dotnet" && $(i + 1) == "add" && $(i + 2) == "package") {
|
|
557
|
+
pkg = $(i + 3)
|
|
558
|
+
for (j = i + 4; j <= NF; j++) {
|
|
559
|
+
if ($j == "--version" && $(j + 1) != "") print pkg "\t" $(j + 1)
|
|
560
|
+
if ($j ~ /^--version=/) { sub(/^--version=/, "", $j); print pkg "\t" $j }
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
'
|
|
354
566
|
}
|
|
355
567
|
|
|
356
568
|
guard_extract_specs() {
|
|
357
|
-
# Echo one "pkg<TAB>spec" line per pkg@spec
|
|
358
|
-
#
|
|
569
|
+
# Echo one "pkg<TAB>spec" line per pkg@spec OPERAND genuinely being installed.
|
|
570
|
+
# Handles @scope/name@spec and bare-name@spec. Two precision rules keep
|
|
571
|
+
# non-package "@" tokens from being misread as an install:
|
|
572
|
+
# 1. Runner segments (npx / pnpm dlx / yarn dlx) contribute ONLY their
|
|
573
|
+
# executed package — trailing tokens are program arguments, not specs
|
|
574
|
+
# (so `npx wrangler ... dev1@block-s.io` is never read as a spec).
|
|
575
|
+
# 2. Email / host operands (user@domain.tld) are never package specs.
|
|
576
|
+
# Each shell segment is judged independently so a genuine install in one
|
|
577
|
+
# segment is still gated even when another segment just runs a tool via npx.
|
|
359
578
|
local cmd="$1"
|
|
360
|
-
|
|
361
|
-
|
|
579
|
+
local seg source=""
|
|
580
|
+
|
|
581
|
+
while IFS= read -r seg; do
|
|
582
|
+
[[ -z "${seg//[[:space:]]/}" ]] && continue
|
|
583
|
+
if printf '%s' "${seg}" | grep -qEi '(^|[[:space:]])(npx|dlx)([[:space:]]|$)'; then
|
|
584
|
+
source+="$(guard_runner_operands "${seg}")"$'\n'
|
|
585
|
+
else
|
|
586
|
+
source+="${seg}"$'\n'
|
|
587
|
+
fi
|
|
588
|
+
done < <(command_candidate_texts "${cmd}" | tr ';|&' '\n')
|
|
589
|
+
|
|
590
|
+
{ printf '%s' "${source}" \
|
|
591
|
+
| grep -oE '(@[a-zA-Z0-9._/-]+/)?[a-zA-Z][a-zA-Z0-9._-]*@[a-zA-Z0-9._^~|<>=*+-]+' || true; } \
|
|
362
592
|
| while IFS= read -r token; do
|
|
593
|
+
# An email / host operand (user@domain.tld) is never a package spec.
|
|
594
|
+
if [[ "${token}" =~ ^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$ ]]; then
|
|
595
|
+
continue
|
|
596
|
+
fi
|
|
363
597
|
local pkg spec
|
|
364
598
|
if [[ "${token}" =~ ^(@[^@]+)@(.+)$ ]]; then
|
|
365
599
|
pkg="${BASH_REMATCH[1]}"
|
|
@@ -370,19 +604,45 @@ guard_extract_specs() {
|
|
|
370
604
|
fi
|
|
371
605
|
printf '%s\t%s\n' "${pkg}" "${spec}"
|
|
372
606
|
done
|
|
607
|
+
printf '%s\n' "${source}" | guard_extract_flagged_specs
|
|
373
608
|
}
|
|
374
609
|
|
|
375
610
|
LEDGER_ECOSYSTEM=$(guard_detect_ecosystem "${COMMAND}")
|
|
376
611
|
LEDGER_SPECS=()
|
|
377
612
|
while IFS= read -r ledger_spec_line; do
|
|
378
613
|
[[ -z "${ledger_spec_line}" ]] && continue
|
|
614
|
+
if [[ ${#LEDGER_SPECS[@]} -gt 0 ]]; then
|
|
615
|
+
for existing_spec_line in "${LEDGER_SPECS[@]}"; do
|
|
616
|
+
[[ "${existing_spec_line}" == "${ledger_spec_line}" ]] && continue 2
|
|
617
|
+
done
|
|
618
|
+
fi
|
|
379
619
|
LEDGER_SPECS+=("${ledger_spec_line}")
|
|
380
620
|
done < <(guard_extract_specs "${COMMAND}")
|
|
381
621
|
|
|
382
|
-
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0
|
|
622
|
+
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
|
|
623
|
+
if [[ ! -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
|
|
624
|
+
# The ledger library is the gate for direct install specs. If it is missing
|
|
625
|
+
# (broken install / moved repo) the gate cannot run — fail CLOSED, observably,
|
|
626
|
+
# instead of falling through to allow.
|
|
627
|
+
log_advisory "pre-guard DENY: ledger library missing (${SAFEDEPS_LEDGER_LIB}) — cannot enforce ${LEDGER_ECOSYSTEM} install, fail-closed."
|
|
628
|
+
jq -nc --arg eco "${LEDGER_ECOSYSTEM}" \
|
|
629
|
+
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("safedeps: the ledger library is missing, so the " + $eco + " install gate cannot run — install blocked fail-closed. Reinstall safedeps: node scripts/install/install-safedeps-hooks.mjs")}}'
|
|
630
|
+
exit 0
|
|
631
|
+
fi
|
|
383
632
|
# shellcheck source=../lib/ledger/ledger.sh
|
|
384
633
|
source "${SAFEDEPS_LEDGER_LIB}"
|
|
385
634
|
|
|
635
|
+
# Resolve a runnable `safedeps` invocation for the block message so the
|
|
636
|
+
# self-heal loop works whether or not the CLI is on PATH. Prefer the PATH
|
|
637
|
+
# command (clean UX); otherwise name the absolute repo bin (quoted via %q so
|
|
638
|
+
# it survives spaces in $HOME). Keeps the gate self-contained — the install
|
|
639
|
+
# of a `~/.local/bin/safedeps` symlink is a convenience, never a requirement.
|
|
640
|
+
if command -v safedeps >/dev/null 2>&1; then
|
|
641
|
+
SAFEDEPS_INVOKE="safedeps"
|
|
642
|
+
else
|
|
643
|
+
printf -v SAFEDEPS_INVOKE '%q' "${SAFEDEPS_REPO_BIN}"
|
|
644
|
+
fi
|
|
645
|
+
|
|
386
646
|
GUARD_BLOCKED_CMDS=()
|
|
387
647
|
for entry in "${LEDGER_SPECS[@]}"; do
|
|
388
648
|
pkg="${entry%%$'\t'*}"
|
|
@@ -390,7 +650,7 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LE
|
|
|
390
650
|
[[ -z "${pkg}" || -z "${spec}" ]] && continue
|
|
391
651
|
if ! safedeps_ledger_check "${LEDGER_ECOSYSTEM}" "${pkg}" "${spec}" 2>/dev/null \
|
|
392
652
|
| jq -e '.approved == true' >/dev/null 2>&1; then
|
|
393
|
-
GUARD_BLOCKED_CMDS+=("
|
|
653
|
+
GUARD_BLOCKED_CMDS+=("${SAFEDEPS_INVOKE} check ${LEDGER_ECOSYSTEM} ${pkg}@${spec}")
|
|
394
654
|
fi
|
|
395
655
|
done
|
|
396
656
|
|
|
@@ -423,5 +683,15 @@ CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --a
|
|
|
423
683
|
'{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
|
|
424
684
|
write_state_file "${GUARD_DIR}/current_state" "${CURRENT_STATE}"
|
|
425
685
|
|
|
686
|
+
if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
|
|
687
|
+
command_is_injectable_npm_install "${COMMAND}" && \
|
|
688
|
+
! command_has_ignore_scripts_flag "${COMMAND}"; then
|
|
689
|
+
UPDATED_COMMAND="${COMMAND} --ignore-scripts"
|
|
690
|
+
mark_ignore_scripts_injected
|
|
691
|
+
jq -nc --arg command "${UPDATED_COMMAND}" \
|
|
692
|
+
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"allow",updatedInput:{command:$command}}}'
|
|
693
|
+
exit 0
|
|
694
|
+
fi
|
|
695
|
+
|
|
426
696
|
# Allow the command to proceed — PostToolUse will verify the result
|
|
427
697
|
exit 0
|