@aldegad/safedeps 2.1.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 +595 -0
- package/LICENSE +190 -0
- package/README.md +311 -0
- package/ROADMAP.md +131 -0
- package/SKILL.md +200 -0
- package/agents/openai.yaml +4 -0
- package/bin/safedeps +842 -0
- package/lib/ledger/ledger.sh +346 -0
- package/lib/providers/providers.sh +479 -0
- package/package.json +41 -0
- package/scripts/install/install-safedeps-hooks.mjs +209 -0
- package/scripts/install/install-safedeps-recheck-agent.mjs +203 -0
- package/scripts/install/migrate-safedeps-state.mjs +91 -0
- package/scripts/safedeps-post-verify.sh +584 -0
- package/scripts/safedeps-pre-guard.sh +427 -0
- package/scripts/safedeps-recheck-alert.sh +115 -0
- package/scripts/test/e2e.sh +107 -0
- package/scripts/test/fixture-provider.mjs +104 -0
- package/scripts/test/smoke.sh +89 -0
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# safedeps: PreToolUse hook
|
|
3
|
+
# Dependency install safety gate with reorg rollback support
|
|
4
|
+
# Detects package install commands and snapshots lock files before execution
|
|
5
|
+
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
|
|
8
|
+
GUARD_DIR="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
|
|
9
|
+
SNAPSHOT_DIR="${GUARD_DIR}/snapshots"
|
|
10
|
+
STATE_LOCK_DIR="${GUARD_DIR}/state.lock"
|
|
11
|
+
|
|
12
|
+
SAFEDEPS_LOCK_FILES=(
|
|
13
|
+
"package-lock.json"
|
|
14
|
+
"pnpm-lock.yaml"
|
|
15
|
+
"yarn.lock"
|
|
16
|
+
"poetry.lock"
|
|
17
|
+
"uv.lock"
|
|
18
|
+
"Pipfile.lock"
|
|
19
|
+
"requirements.txt"
|
|
20
|
+
"Cargo.lock"
|
|
21
|
+
"go.sum"
|
|
22
|
+
"Gemfile.lock"
|
|
23
|
+
"packages.lock.json"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
SAFEDEPS_MANIFEST_FILES=(
|
|
27
|
+
"package.json"
|
|
28
|
+
"pyproject.toml"
|
|
29
|
+
"Pipfile"
|
|
30
|
+
"Cargo.toml"
|
|
31
|
+
"go.mod"
|
|
32
|
+
"Gemfile"
|
|
33
|
+
"pom.xml"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
umask 077
|
|
37
|
+
mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
|
|
38
|
+
|
|
39
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
40
|
+
echo "safedeps: jq is not installed; skipping guard hook." >&2
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
|
|
44
|
+
acquire_state_lock() {
|
|
45
|
+
local attempts=0
|
|
46
|
+
|
|
47
|
+
while ! mkdir "${STATE_LOCK_DIR}" 2>/dev/null; do
|
|
48
|
+
# Detect stale locks left by SIGKILL/OOM (V-005)
|
|
49
|
+
if [[ -d "${STATE_LOCK_DIR}" ]]; then
|
|
50
|
+
local lock_mtime=""
|
|
51
|
+
if lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null) || \
|
|
52
|
+
lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null); then
|
|
53
|
+
local now
|
|
54
|
+
now=$(date +%s)
|
|
55
|
+
if [[ $(( now - lock_mtime )) -gt 60 ]]; then
|
|
56
|
+
echo "safedeps: removing stale lock ($(( now - lock_mtime ))s old)." >&2
|
|
57
|
+
rmdir "${STATE_LOCK_DIR}" 2>/dev/null || true
|
|
58
|
+
continue
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
attempts=$((attempts + 1))
|
|
64
|
+
if [[ ${attempts} -ge 100 ]]; then
|
|
65
|
+
echo "safedeps: could not acquire state lock; skipping guard hook." >&2
|
|
66
|
+
exit 0
|
|
67
|
+
fi
|
|
68
|
+
sleep 0.1
|
|
69
|
+
done
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
release_state_lock() {
|
|
73
|
+
rmdir "${STATE_LOCK_DIR}" 2>/dev/null || true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
write_state_file() {
|
|
77
|
+
local target_path="$1"
|
|
78
|
+
local value="$2"
|
|
79
|
+
local temp_path="${target_path}.$$"
|
|
80
|
+
|
|
81
|
+
printf '%s\n' "${value}" > "${temp_path}"
|
|
82
|
+
mv "${temp_path}" "${target_path}"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
compute_dir_hash() {
|
|
86
|
+
local input_dir="$1"
|
|
87
|
+
|
|
88
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
89
|
+
printf '%s' "${input_dir}" | md5sum | cut -d' ' -f1
|
|
90
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
91
|
+
md5 -q -s "${input_dir}"
|
|
92
|
+
else
|
|
93
|
+
printf '%s' "${input_dir}" | cksum | cut -d' ' -f1
|
|
94
|
+
fi
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
command_is_dependency_install() {
|
|
98
|
+
local command="$1"
|
|
99
|
+
local scan_command
|
|
100
|
+
local install_pattern
|
|
101
|
+
|
|
102
|
+
scan_command=$(command_scan_text "${command}")
|
|
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:]]|$)'
|
|
104
|
+
|
|
105
|
+
echo "${scan_command}" | grep -qEi "${install_pattern}"
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
command_hides_dependency_install() {
|
|
109
|
+
local command="$1"
|
|
110
|
+
local manager_pattern
|
|
111
|
+
local verb_pattern
|
|
112
|
+
|
|
113
|
+
manager_pattern='(npm|npx|pnpm|yarn|pip3?|python3?[[:space:]]+-m[[:space:]]+pip|poetry|uv|pipenv|cargo|go|gem|bundle|mvn|dotnet)'
|
|
114
|
+
verb_pattern='(install|i|add|update|up|upgrade|dlx|get|dependency:get|package)'
|
|
115
|
+
|
|
116
|
+
echo "${command}" | grep -qEi '(eval[[:space:]]|\$\(|`)' && \
|
|
117
|
+
echo "${command}" | grep -qEi "${manager_pattern}.*${verb_pattern}"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
command_scan_text() {
|
|
121
|
+
local input="$1"
|
|
122
|
+
local output=""
|
|
123
|
+
local quote=""
|
|
124
|
+
local char
|
|
125
|
+
local prev=""
|
|
126
|
+
local i
|
|
127
|
+
|
|
128
|
+
for ((i = 0; i < ${#input}; i++)); do
|
|
129
|
+
char="${input:i:1}"
|
|
130
|
+
|
|
131
|
+
if [[ -z "${quote}" ]]; then
|
|
132
|
+
if [[ "${char}" == "'" ]]; then
|
|
133
|
+
quote="single"
|
|
134
|
+
output="${output} "
|
|
135
|
+
elif [[ "${char}" == '"' ]]; then
|
|
136
|
+
quote="double"
|
|
137
|
+
output="${output} "
|
|
138
|
+
else
|
|
139
|
+
output="${output}${char}"
|
|
140
|
+
fi
|
|
141
|
+
elif [[ "${quote}" == "single" && "${char}" == "'" ]]; then
|
|
142
|
+
quote=""
|
|
143
|
+
output="${output} "
|
|
144
|
+
elif [[ "${quote}" == "double" && "${char}" == '"' && "${prev}" != "\\" ]]; then
|
|
145
|
+
quote=""
|
|
146
|
+
output="${output} "
|
|
147
|
+
else
|
|
148
|
+
output="${output} "
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
prev="${char}"
|
|
152
|
+
done
|
|
153
|
+
|
|
154
|
+
printf '%s' "${output}"
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
snapshot_project_file() {
|
|
158
|
+
local relative_file="$1"
|
|
159
|
+
local category="${2:-manifest}"
|
|
160
|
+
local source_path="${PROJECT_DIR}/${relative_file}"
|
|
161
|
+
local snapshot_path="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${relative_file}"
|
|
162
|
+
|
|
163
|
+
printf '%s\n' "${relative_file}" >> "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_monitored_files.list"
|
|
164
|
+
|
|
165
|
+
if [[ -f "${source_path}" ]]; then
|
|
166
|
+
cp "${source_path}" "${snapshot_path}"
|
|
167
|
+
if command -v shasum &>/dev/null; then
|
|
168
|
+
shasum -a 256 "${source_path}" > "${snapshot_path}.sha256"
|
|
169
|
+
elif command -v sha256sum &>/dev/null; then
|
|
170
|
+
sha256sum "${source_path}" > "${snapshot_path}.sha256"
|
|
171
|
+
fi
|
|
172
|
+
if [[ "${category}" == "lock" ]]; then
|
|
173
|
+
SNAPSHOTTED=true
|
|
174
|
+
fi
|
|
175
|
+
else
|
|
176
|
+
touch "${snapshot_path}.missing"
|
|
177
|
+
fi
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# Read tool input from stdin
|
|
181
|
+
INPUT=$(cat)
|
|
182
|
+
|
|
183
|
+
# Extract tool name and command
|
|
184
|
+
TOOL_NAME=$(echo "${INPUT}" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
185
|
+
COMMAND=$(echo "${INPUT}" | jq -r '.tool_input.command // empty' 2>/dev/null)
|
|
186
|
+
|
|
187
|
+
# Only intercept Bash tool calls
|
|
188
|
+
if [[ "${TOOL_NAME}" != "Bash" ]] || [[ -z "${COMMAND}" ]]; then
|
|
189
|
+
exit 0
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
if ! command_is_dependency_install "${COMMAND}"; then
|
|
193
|
+
# Catch indirection patterns that hide install commands (V-002)
|
|
194
|
+
if command_hides_dependency_install "${COMMAND}"; then
|
|
195
|
+
: # Fall through — treat as install candidate
|
|
196
|
+
else
|
|
197
|
+
exit 0
|
|
198
|
+
fi
|
|
199
|
+
fi
|
|
200
|
+
|
|
201
|
+
# --- Reorg Guard Activated ---
|
|
202
|
+
|
|
203
|
+
# Find lock files in common locations
|
|
204
|
+
# Per Claude Code / Codex CLI hook spec, `cwd` is top-level. Fall back to `pwd`
|
|
205
|
+
# only when the hook is invoked outside the engine (manual test, no stdin payload).
|
|
206
|
+
PROJECT_DIR=$(echo "${INPUT}" | jq -r '.cwd // empty' 2>/dev/null)
|
|
207
|
+
if [[ -z "${PROJECT_DIR}" ]]; then
|
|
208
|
+
PROJECT_DIR=$(pwd)
|
|
209
|
+
fi
|
|
210
|
+
|
|
211
|
+
# Canonicalize to prevent path traversal (V-003)
|
|
212
|
+
if command -v realpath >/dev/null 2>&1; then
|
|
213
|
+
PROJECT_DIR=$(realpath "${PROJECT_DIR}" 2>/dev/null || echo "${PROJECT_DIR}")
|
|
214
|
+
elif command -v readlink >/dev/null 2>&1; then
|
|
215
|
+
PROJECT_DIR=$(readlink -f "${PROJECT_DIR}" 2>/dev/null || echo "${PROJECT_DIR}")
|
|
216
|
+
fi
|
|
217
|
+
|
|
218
|
+
TIMESTAMP=$(date +%s)
|
|
219
|
+
DIR_HASH=$(compute_dir_hash "${PROJECT_DIR}")
|
|
220
|
+
SNAPSHOT_ID="${TIMESTAMP}_${DIR_HASH}"
|
|
221
|
+
|
|
222
|
+
acquire_state_lock
|
|
223
|
+
trap 'release_state_lock' EXIT
|
|
224
|
+
|
|
225
|
+
PARENT_SNAPSHOT_ID=""
|
|
226
|
+
CONFIRMED_FILE="${GUARD_DIR}/confirmed_${DIR_HASH}"
|
|
227
|
+
if [[ -f "${CONFIRMED_FILE}" ]]; then
|
|
228
|
+
PARENT_SNAPSHOT_ID=$(cat "${CONFIRMED_FILE}" 2>/dev/null || true)
|
|
229
|
+
fi
|
|
230
|
+
|
|
231
|
+
if [[ -n "${PARENT_SNAPSHOT_ID}" ]] && [[ ! -f "${SNAPSHOT_DIR}/${PARENT_SNAPSHOT_ID}_meta.json" ]]; then
|
|
232
|
+
# Fallback: check legacy global confirmed file for migration
|
|
233
|
+
if [[ -f "${GUARD_DIR}/confirmed" ]]; then
|
|
234
|
+
PARENT_SNAPSHOT_ID=$(cat "${GUARD_DIR}/confirmed" 2>/dev/null || true)
|
|
235
|
+
if [[ -n "${PARENT_SNAPSHOT_ID}" ]] && [[ ! -f "${SNAPSHOT_DIR}/${PARENT_SNAPSHOT_ID}_meta.json" ]]; then
|
|
236
|
+
PARENT_SNAPSHOT_ID=""
|
|
237
|
+
fi
|
|
238
|
+
else
|
|
239
|
+
PARENT_SNAPSHOT_ID=""
|
|
240
|
+
fi
|
|
241
|
+
fi
|
|
242
|
+
|
|
243
|
+
PARENT_SNAPSHOT_JSON=$(printf '%s' "${PARENT_SNAPSHOT_ID}" | jq -Rs 'if length == 0 then null else . end')
|
|
244
|
+
|
|
245
|
+
# Snapshot lock and manifest files that define dependency truth.
|
|
246
|
+
SNAPSHOTTED=false
|
|
247
|
+
: > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_monitored_files.list"
|
|
248
|
+
|
|
249
|
+
for lock_file in "${SAFEDEPS_LOCK_FILES[@]}"; do
|
|
250
|
+
snapshot_project_file "${lock_file}" "lock"
|
|
251
|
+
done
|
|
252
|
+
|
|
253
|
+
for manifest_file in "${SAFEDEPS_MANIFEST_FILES[@]}"; do
|
|
254
|
+
snapshot_project_file "${manifest_file}" "manifest"
|
|
255
|
+
done
|
|
256
|
+
|
|
257
|
+
while IFS= read -r csproj_file; do
|
|
258
|
+
snapshot_project_file "$(basename "${csproj_file}")" "manifest"
|
|
259
|
+
done < <(find "${PROJECT_DIR}" -maxdepth 1 -type f -name "*.csproj" 2>/dev/null | sort)
|
|
260
|
+
|
|
261
|
+
# Save pre-install listings for diff-based detection (avoids mtime-based find -newer)
|
|
262
|
+
if [[ -d "${PROJECT_DIR}/node_modules" ]]; then
|
|
263
|
+
find "${PROJECT_DIR}/node_modules" -maxdepth 3 -name "package.json" 2>/dev/null | sort > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_packages.list"
|
|
264
|
+
{ ls "${PROJECT_DIR}/node_modules/.bin/" 2>/dev/null || true; } | sort > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_bins.list"
|
|
265
|
+
else
|
|
266
|
+
touch "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_packages.list"
|
|
267
|
+
touch "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_bins.list"
|
|
268
|
+
fi
|
|
269
|
+
|
|
270
|
+
# Store metadata for PostToolUse verification
|
|
271
|
+
cat > "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json" << META_EOF
|
|
272
|
+
{
|
|
273
|
+
"snapshot_id": "${SNAPSHOT_ID}",
|
|
274
|
+
"parent_snapshot_id": ${PARENT_SNAPSHOT_JSON},
|
|
275
|
+
"timestamp": ${TIMESTAMP},
|
|
276
|
+
"project_dir": $(printf '%s' "${PROJECT_DIR}" | jq -Rs .),
|
|
277
|
+
"command": $(printf '%s' "${COMMAND}" | jq -Rs .),
|
|
278
|
+
"lock_files_found": ${SNAPSHOTTED}
|
|
279
|
+
}
|
|
280
|
+
META_EOF
|
|
281
|
+
|
|
282
|
+
# --- Pre-flight security checks on the command itself ---
|
|
283
|
+
|
|
284
|
+
SUSPICIOUS=false
|
|
285
|
+
REASONS=()
|
|
286
|
+
|
|
287
|
+
# Check for piped install from suspicious sources
|
|
288
|
+
if echo "${COMMAND}" | grep -qEi 'curl.*\|[[:space:]]*(bash|sh|node)'; then
|
|
289
|
+
SUSPICIOUS=true
|
|
290
|
+
REASONS+=("Command pipes remote content to shell execution")
|
|
291
|
+
fi
|
|
292
|
+
|
|
293
|
+
# Check for install with --ignore-scripts being removed (attacker might want scripts to run)
|
|
294
|
+
if echo "${COMMAND}" | grep -qEi 'npm[[:space:]]+config[[:space:]]+set[[:space:]]+ignore-scripts[[:space:]]+false'; then
|
|
295
|
+
SUSPICIOUS=true
|
|
296
|
+
REASONS+=("Command explicitly enables install scripts")
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
# Check for registry override to unknown registry
|
|
300
|
+
if echo "${COMMAND}" | grep -qEi -- '--registry([=[:space:]]+)'; then
|
|
301
|
+
if ! echo "${COMMAND}" | grep -qEi -- '--registry([=[:space:]]+)https?://(registry\.npmjs\.org|registry\.yarnpkg\.com)(/|[[:space:]]|$)'; then
|
|
302
|
+
SUSPICIOUS=true
|
|
303
|
+
REASONS+=("Command uses non-standard npm registry")
|
|
304
|
+
fi
|
|
305
|
+
fi
|
|
306
|
+
|
|
307
|
+
# Check for packages with suspicious naming patterns (typosquatting indicators)
|
|
308
|
+
TYPOSQUAT_PATTERNS='(lod[bcdfghjklmnpqrstvwxyz]sh|lodahs|loadsh|lodashh|reacct|exprss|axois|babeel|webpackk|esliint|l0dash|m0ment|4xios|reqeusts|requets|djagno|numppy|panddas|pilliow|tensorfow|scikit-learnn|serde_jsonn|tokioo|reqwestt|clapp|github\.con/|githb\.com/|railss|sinatraa|nokogirri|log4jj|springframewrok|commons-collectionss|newtonsoft\.josn|serilogg|nunittt)'
|
|
309
|
+
if echo "${COMMAND}" | grep -qEi "${TYPOSQUAT_PATTERNS}"; then
|
|
310
|
+
SUSPICIOUS=true
|
|
311
|
+
REASONS+=("Package name matches known typosquatting patterns")
|
|
312
|
+
fi
|
|
313
|
+
|
|
314
|
+
if [[ "${SUSPICIOUS}" == "true" ]]; then
|
|
315
|
+
REASON_STR=$(printf '%s; ' "${REASONS[@]}")
|
|
316
|
+
jq -nc --arg reason "safedeps: ${REASON_STR%%; }" \
|
|
317
|
+
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason}}'
|
|
318
|
+
exit 0
|
|
319
|
+
fi
|
|
320
|
+
|
|
321
|
+
# --- Phase 2 advisory gate — ledger enforcement -------------------------------
|
|
322
|
+
# For commands that name specific packages, require an entry in the approved-
|
|
323
|
+
# spec ledger. Miss/expired → block with a structured message that names the
|
|
324
|
+
# exact `safedeps check` command the caller (agent or human) should run next.
|
|
325
|
+
#
|
|
326
|
+
# Conservative: only block when at least one pkg@spec token is parseable. Bare
|
|
327
|
+
# `npm install` (lockfile install) falls through to the v1 reorg checks.
|
|
328
|
+
|
|
329
|
+
SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
|
|
330
|
+
SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
|
|
331
|
+
|
|
332
|
+
guard_detect_ecosystem() {
|
|
333
|
+
local cmd="$1"
|
|
334
|
+
local scan_cmd
|
|
335
|
+
|
|
336
|
+
scan_cmd=$(command_scan_text "${cmd}")
|
|
337
|
+
if echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(npm|pnpm|yarn|npx)([[:space:]]|$)'; then
|
|
338
|
+
printf 'npm'
|
|
339
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(pip3?|poetry|uv|pipenv|((python3?|py)[[:space:]]+-m[[:space:]]+pip))([[:space:]]|$)'; then
|
|
340
|
+
printf 'pypi'
|
|
341
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)cargo([[:space:]]|$)'; then
|
|
342
|
+
printf 'crates.io'
|
|
343
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)go([[:space:]]|$)'; then
|
|
344
|
+
printf 'go'
|
|
345
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)(gem|bundle)([[:space:]]|$)'; then
|
|
346
|
+
printf 'rubygems'
|
|
347
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)mvn([[:space:]]|$)'; then
|
|
348
|
+
printf 'maven'
|
|
349
|
+
elif echo "${scan_cmd}" | grep -qEi '(^|[;&|]+[[:space:]]*)dotnet([[:space:]]|$)'; then
|
|
350
|
+
printf 'nuget'
|
|
351
|
+
else
|
|
352
|
+
printf ''
|
|
353
|
+
fi
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
guard_extract_specs() {
|
|
357
|
+
# Echo one "pkg<TAB>spec" line per pkg@spec token found in the command.
|
|
358
|
+
# Captures @scope/name@spec and bare-name@spec forms.
|
|
359
|
+
local cmd="$1"
|
|
360
|
+
echo "${cmd}" \
|
|
361
|
+
| grep -oE '(@[a-zA-Z0-9._/-]+/)?[a-zA-Z][a-zA-Z0-9._-]*@[a-zA-Z0-9._^~|<>=*+-]+' \
|
|
362
|
+
| while IFS= read -r token; do
|
|
363
|
+
local pkg spec
|
|
364
|
+
if [[ "${token}" =~ ^(@[^@]+)@(.+)$ ]]; then
|
|
365
|
+
pkg="${BASH_REMATCH[1]}"
|
|
366
|
+
spec="${BASH_REMATCH[2]}"
|
|
367
|
+
else
|
|
368
|
+
pkg="${token%@*}"
|
|
369
|
+
spec="${token##*@}"
|
|
370
|
+
fi
|
|
371
|
+
printf '%s\t%s\n' "${pkg}" "${spec}"
|
|
372
|
+
done
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
LEDGER_ECOSYSTEM=$(guard_detect_ecosystem "${COMMAND}")
|
|
376
|
+
LEDGER_SPECS=()
|
|
377
|
+
while IFS= read -r ledger_spec_line; do
|
|
378
|
+
[[ -z "${ledger_spec_line}" ]] && continue
|
|
379
|
+
LEDGER_SPECS+=("${ledger_spec_line}")
|
|
380
|
+
done < <(guard_extract_specs "${COMMAND}")
|
|
381
|
+
|
|
382
|
+
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
|
|
383
|
+
# shellcheck source=../lib/ledger/ledger.sh
|
|
384
|
+
source "${SAFEDEPS_LEDGER_LIB}"
|
|
385
|
+
|
|
386
|
+
GUARD_BLOCKED_CMDS=()
|
|
387
|
+
for entry in "${LEDGER_SPECS[@]}"; do
|
|
388
|
+
pkg="${entry%%$'\t'*}"
|
|
389
|
+
spec="${entry##*$'\t'}"
|
|
390
|
+
[[ -z "${pkg}" || -z "${spec}" ]] && continue
|
|
391
|
+
if ! safedeps_ledger_check "${LEDGER_ECOSYSTEM}" "${pkg}" "${spec}" 2>/dev/null \
|
|
392
|
+
| jq -e '.approved == true' >/dev/null 2>&1; then
|
|
393
|
+
GUARD_BLOCKED_CMDS+=("safedeps check ${LEDGER_ECOSYSTEM} ${pkg}@${spec}")
|
|
394
|
+
fi
|
|
395
|
+
done
|
|
396
|
+
|
|
397
|
+
if [[ ${#GUARD_BLOCKED_CMDS[@]} -gt 0 ]]; then
|
|
398
|
+
NEXT_CMD=""
|
|
399
|
+
for ((i = 0; i < ${#GUARD_BLOCKED_CMDS[@]}; i++)); do
|
|
400
|
+
if [[ -z "${NEXT_CMD}" ]]; then
|
|
401
|
+
NEXT_CMD="${GUARD_BLOCKED_CMDS[$i]}"
|
|
402
|
+
else
|
|
403
|
+
NEXT_CMD="${NEXT_CMD} && ${GUARD_BLOCKED_CMDS[$i]}"
|
|
404
|
+
fi
|
|
405
|
+
done
|
|
406
|
+
REASON_JSON=$(jq -nc \
|
|
407
|
+
--arg next "${NEXT_CMD}" \
|
|
408
|
+
--arg ecosystem "${LEDGER_ECOSYSTEM}" \
|
|
409
|
+
'{
|
|
410
|
+
hookSpecificOutput: {
|
|
411
|
+
hookEventName: "PreToolUse",
|
|
412
|
+
permissionDecision: "deny",
|
|
413
|
+
permissionDecisionReason: ("safedeps: install not approved (ecosystem=" + $ecosystem + ") — run `" + $next + "` first, then retry the install using the approved version (see install_hint in the check output).")
|
|
414
|
+
}
|
|
415
|
+
}')
|
|
416
|
+
printf '%s\n' "${REASON_JSON}"
|
|
417
|
+
exit 0
|
|
418
|
+
fi
|
|
419
|
+
fi
|
|
420
|
+
|
|
421
|
+
# Write current state atomically for PostToolUse (V-004: single file prevents TOCTOU)
|
|
422
|
+
CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --arg dhash "${DIR_HASH}" \
|
|
423
|
+
'{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
|
|
424
|
+
write_state_file "${GUARD_DIR}/current_state" "${CURRENT_STATE}"
|
|
425
|
+
|
|
426
|
+
# Allow the command to proceed — PostToolUse will verify the result
|
|
427
|
+
exit 0
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Run safedeps re-check and notify only when attention is needed.
|
|
3
|
+
|
|
4
|
+
set -euo pipefail
|
|
5
|
+
|
|
6
|
+
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
7
|
+
SAFEDEPS_BIN="${SAFEDEPS_BIN:-${ROOT_DIR}/bin/safedeps}"
|
|
8
|
+
SAFEDEPS_HOME="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
|
|
9
|
+
SAFEDEPS_RECHECK_LOG="${SAFEDEPS_RECHECK_LOG:-${SAFEDEPS_HOME}/recheck.log}"
|
|
10
|
+
SAFEDEPS_RECHECK_ERR_LOG="${SAFEDEPS_RECHECK_ERR_LOG:-${SAFEDEPS_HOME}/recheck.err.log}"
|
|
11
|
+
SAFEDEPS_RECHECK_ALERTS="${SAFEDEPS_RECHECK_ALERTS:-${SAFEDEPS_HOME}/recheck-alerts.jsonl}"
|
|
12
|
+
SAFEDEPS_NOTIFY="${SAFEDEPS_NOTIFY:-1}"
|
|
13
|
+
|
|
14
|
+
mkdir -p "${SAFEDEPS_HOME}" "$(dirname "${SAFEDEPS_RECHECK_LOG}")" "$(dirname "${SAFEDEPS_RECHECK_ALERTS}")"
|
|
15
|
+
|
|
16
|
+
now_utc() {
|
|
17
|
+
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
notify() {
|
|
21
|
+
local title="$1"
|
|
22
|
+
local message="$2"
|
|
23
|
+
|
|
24
|
+
[[ "${SAFEDEPS_NOTIFY}" == "1" ]] || return 0
|
|
25
|
+
command -v osascript >/dev/null 2>&1 || return 0
|
|
26
|
+
|
|
27
|
+
osascript - "${title}" "${message}" <<'OSA' >/dev/null 2>&1 || true
|
|
28
|
+
on run argv
|
|
29
|
+
display notification (item 2 of argv) with title (item 1 of argv)
|
|
30
|
+
end run
|
|
31
|
+
OSA
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
append_alert() {
|
|
35
|
+
local alert_json="$1"
|
|
36
|
+
printf '%s\n' "${alert_json}" >> "${SAFEDEPS_RECHECK_ALERTS}"
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
tmp_root="${TMPDIR:-/tmp}"
|
|
40
|
+
mkdir -p "${tmp_root}"
|
|
41
|
+
tmp_json=$(mktemp "${tmp_root%/}/safedeps-recheck.XXXXXX")
|
|
42
|
+
tmp_err=$(mktemp "${tmp_root%/}/safedeps-recheck-err.XXXXXX")
|
|
43
|
+
cleanup() {
|
|
44
|
+
rm -f "${tmp_json}" "${tmp_err}"
|
|
45
|
+
}
|
|
46
|
+
trap cleanup EXIT
|
|
47
|
+
|
|
48
|
+
run_at=$(now_utc)
|
|
49
|
+
status=0
|
|
50
|
+
|
|
51
|
+
if [[ -n "${SAFEDEPS_RECHECK_FIXTURE_JSON:-}" ]]; then
|
|
52
|
+
cat "${SAFEDEPS_RECHECK_FIXTURE_JSON}" > "${tmp_json}"
|
|
53
|
+
else
|
|
54
|
+
"${SAFEDEPS_BIN}" re-check --json > "${tmp_json}" 2> "${tmp_err}" || status=$?
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
printf '[%s] safedeps re-check status=%s\n' "${run_at}" "${status}"
|
|
59
|
+
cat "${tmp_json}" 2>/dev/null || true
|
|
60
|
+
printf '\n'
|
|
61
|
+
} >> "${SAFEDEPS_RECHECK_LOG}"
|
|
62
|
+
|
|
63
|
+
if [[ -s "${tmp_err}" ]]; then
|
|
64
|
+
{
|
|
65
|
+
printf '[%s] safedeps re-check stderr\n' "${run_at}"
|
|
66
|
+
cat "${tmp_err}"
|
|
67
|
+
printf '\n'
|
|
68
|
+
} >> "${SAFEDEPS_RECHECK_ERR_LOG}"
|
|
69
|
+
fi
|
|
70
|
+
|
|
71
|
+
if [[ "${status}" -ne 0 ]]; then
|
|
72
|
+
alert=$(jq -cn \
|
|
73
|
+
--arg at "${run_at}" \
|
|
74
|
+
--argjson status "${status}" \
|
|
75
|
+
--rawfile stderr "${tmp_err}" \
|
|
76
|
+
'{kind:"recheck_failed", at:$at, exit_status:$status, stderr:$stderr}')
|
|
77
|
+
append_alert "${alert}"
|
|
78
|
+
notify "safedeps re-check failed" "Daily dependency approval re-check exited ${status}. See ~/.safedeps/recheck.err.log"
|
|
79
|
+
exit "${status}"
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
if ! jq -e '.command == "re-check"' "${tmp_json}" >/dev/null 2>&1; then
|
|
83
|
+
alert=$(jq -cn \
|
|
84
|
+
--arg at "${run_at}" \
|
|
85
|
+
--rawfile output "${tmp_json}" \
|
|
86
|
+
'{kind:"recheck_invalid_output", at:$at, output:$output}')
|
|
87
|
+
append_alert "${alert}"
|
|
88
|
+
notify "safedeps re-check failed" "Daily re-check returned invalid JSON. See ~/.safedeps/recheck.log"
|
|
89
|
+
exit 1
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
checked=$(jq -r '.checked // 0' "${tmp_json}")
|
|
93
|
+
still_clean=$(jq -r '.still_clean // 0' "${tmp_json}")
|
|
94
|
+
newly_vulnerable=$(jq -r '(.newly_vulnerable // []) | length' "${tmp_json}")
|
|
95
|
+
kev_hit=$(jq -r '(.kev_hit // []) | length' "${tmp_json}")
|
|
96
|
+
revoked=$(jq -r '(.revoked // []) | length' "${tmp_json}")
|
|
97
|
+
skipped=$(( checked - still_clean - revoked ))
|
|
98
|
+
if [[ "${skipped}" -lt 0 ]]; then
|
|
99
|
+
skipped=0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
if [[ "${newly_vulnerable}" -gt 0 || "${kev_hit}" -gt 0 || "${revoked}" -gt 0 || "${skipped}" -gt 0 ]]; then
|
|
103
|
+
alert=$(jq -c \
|
|
104
|
+
--arg at "${run_at}" \
|
|
105
|
+
--argjson skipped "${skipped}" \
|
|
106
|
+
'. + {kind:"recheck_attention", at:$at, provider_skipped:$skipped}' \
|
|
107
|
+
"${tmp_json}")
|
|
108
|
+
append_alert "${alert}"
|
|
109
|
+
|
|
110
|
+
message="${revoked} revoked, ${newly_vulnerable} new CVE, ${kev_hit} KEV"
|
|
111
|
+
if [[ "${skipped}" -gt 0 ]]; then
|
|
112
|
+
message="${message}, ${skipped} provider skipped"
|
|
113
|
+
fi
|
|
114
|
+
notify "safedeps attention needed" "${message}. See ~/.safedeps/recheck-alerts.jsonl"
|
|
115
|
+
fi
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
|
5
|
+
cd "${ROOT_DIR}"
|
|
6
|
+
|
|
7
|
+
pass() {
|
|
8
|
+
printf 'ok - %s\n' "$1"
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
fail() {
|
|
12
|
+
printf 'not ok - %s\n' "$1" >&2
|
|
13
|
+
exit 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
tmp_root=$(mktemp -d "${TMPDIR:-/tmp}/safedeps-e2e.XXXXXX")
|
|
17
|
+
cleanup() {
|
|
18
|
+
if [[ -n "${server_pid:-}" ]]; then
|
|
19
|
+
kill "${server_pid}" 2>/dev/null || true
|
|
20
|
+
wait "${server_pid}" 2>/dev/null || true
|
|
21
|
+
fi
|
|
22
|
+
rm -rf "${tmp_root}"
|
|
23
|
+
}
|
|
24
|
+
trap cleanup EXIT
|
|
25
|
+
|
|
26
|
+
port_file="${tmp_root}/port"
|
|
27
|
+
state_file="${tmp_root}/state.json"
|
|
28
|
+
printf '%s\n' '{"vulnerable":[]}' > "${state_file}"
|
|
29
|
+
node scripts/test/fixture-provider.mjs "${port_file}" "${state_file}" &
|
|
30
|
+
server_pid=$!
|
|
31
|
+
|
|
32
|
+
for _ in {1..50}; do
|
|
33
|
+
[[ -s "${port_file}" ]] && break
|
|
34
|
+
sleep 0.1
|
|
35
|
+
done
|
|
36
|
+
[[ -s "${port_file}" ]] || fail "fixture provider starts"
|
|
37
|
+
port=$(cat "${port_file}")
|
|
38
|
+
|
|
39
|
+
export SAFEDEPS_HOME="${tmp_root}/safe"
|
|
40
|
+
export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
|
|
41
|
+
export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
|
|
42
|
+
export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
|
|
43
|
+
export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
|
|
44
|
+
|
|
45
|
+
clean_json=$(./bin/safedeps --json check npm fixture-clean@1.0.0)
|
|
46
|
+
[[ "$(jq -r '.result' <<< "${clean_json}")" == "clean" ]] || fail "clean fixture approved"
|
|
47
|
+
pass "clean advisory approval"
|
|
48
|
+
|
|
49
|
+
patched_json=$(./bin/safedeps --json check npm fixture-vuln@1.0.0)
|
|
50
|
+
[[ "$(jq -r '.result' <<< "${patched_json}")" == "patched_available" ]] || fail "patched fixture narrows"
|
|
51
|
+
[[ "$(jq -r '.suggested_spec' <<< "${patched_json}")" == "1.0.1" ]] || fail "patched fixture suggests fixed version"
|
|
52
|
+
pass "patched advisory narrowing"
|
|
53
|
+
|
|
54
|
+
set +e
|
|
55
|
+
unpatched_json=$(./bin/safedeps --json check npm fixture-unpatched@1.0.0)
|
|
56
|
+
unpatched_status=$?
|
|
57
|
+
kev_json=$(./bin/safedeps --json check npm fixture-kev@1.0.0)
|
|
58
|
+
kev_status=$?
|
|
59
|
+
set -e
|
|
60
|
+
[[ "${unpatched_status}" -eq 2 ]] || fail "unpatched fixture exits 2"
|
|
61
|
+
[[ "$(jq -r '.result' <<< "${unpatched_json}")" == "cve_unpatched" ]] || fail "unpatched fixture reports cve_unpatched"
|
|
62
|
+
[[ "${kev_status}" -eq 3 ]] || fail "kev fixture exits 3"
|
|
63
|
+
[[ "$(jq -r '.result' <<< "${kev_json}")" == "kev_hard_block" ]] || fail "kev fixture reports kev_hard_block"
|
|
64
|
+
pass "block classifications"
|
|
65
|
+
|
|
66
|
+
project_dir="${tmp_root}/project"
|
|
67
|
+
mkdir -p "${project_dir}"
|
|
68
|
+
printf '{"dependencies":{}}\n' > "${project_dir}/package.json"
|
|
69
|
+
hook_allow=$(
|
|
70
|
+
scripts/safedeps-pre-guard.sh <<EOF
|
|
71
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-vuln@1.0.1"},"cwd":"${project_dir}"}
|
|
72
|
+
EOF
|
|
73
|
+
)
|
|
74
|
+
[[ -z "${hook_allow}" ]] || fail "hook allows narrowed approved spec"
|
|
75
|
+
pass "hook allows approved narrowed spec"
|
|
76
|
+
|
|
77
|
+
printf '%s\n' '{"vulnerable":["fixture-clean@1.0.0"]}' > "${state_file}"
|
|
78
|
+
recheck_json=$(./bin/safedeps --json re-check)
|
|
79
|
+
[[ "$(jq -r '.revoked | length' <<< "${recheck_json}")" == "1" ]] || fail "re-check revokes newly vulnerable spec"
|
|
80
|
+
[[ "$(jq -r '.revoked[0].package' <<< "${recheck_json}")" == "fixture-clean" ]] || fail "re-check revoked expected package"
|
|
81
|
+
pass "re-check revocation"
|
|
82
|
+
|
|
83
|
+
legacy_home="${tmp_root}/legacy"
|
|
84
|
+
target_home="${tmp_root}/migrated"
|
|
85
|
+
mkdir -p "${legacy_home}/approved-specs"
|
|
86
|
+
printf 'legacy\n' > "${legacy_home}/approved-specs/example.json"
|
|
87
|
+
migrate_json=$(SAFEDEPS_LEGACY_HOME="${legacy_home}" SAFEDEPS_HOME="${target_home}" ./bin/safedeps --json migrate)
|
|
88
|
+
[[ "$(jq -r '.migrated' <<< "${migrate_json}")" == "true" ]] || fail "legacy state migrated"
|
|
89
|
+
[[ -f "${target_home}/approved-specs/example.json" ]] || fail "legacy state copied"
|
|
90
|
+
[[ ! -e "${legacy_home}" ]] || fail "legacy root archived"
|
|
91
|
+
pass "legacy state migration"
|
|
92
|
+
|
|
93
|
+
installer_home="${tmp_root}/installer-home"
|
|
94
|
+
mkdir -p "${installer_home}/.claude" "${installer_home}/.codex"
|
|
95
|
+
cat > "${installer_home}/.claude/settings.json" <<EOF
|
|
96
|
+
{"hooks":{"PreToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"${installer_home}/.claude/skills/npm-reorg-guard/scripts/guard.sh"}]}],"PostToolUse":[{"matcher":"Bash","hooks":[{"type":"command","command":"${installer_home}/.claude/skills/npm-reorg-guard/scripts/verify.sh"}]}]}}
|
|
97
|
+
EOF
|
|
98
|
+
HOME="${installer_home}" node scripts/install/install-safedeps-hooks.mjs >/dev/null
|
|
99
|
+
jq -e --arg pre "${ROOT_DIR}/scripts/safedeps-pre-guard.sh" '
|
|
100
|
+
[.hooks.PreToolUse[]?.hooks[]?.command] | index($pre)
|
|
101
|
+
' "${installer_home}/.claude/settings.json" >/dev/null || fail "installer writes new pre hook"
|
|
102
|
+
if jq -e '[.. | strings] | any(contains("npm-reorg-guard"))' "${installer_home}/.claude/settings.json" >/dev/null; then
|
|
103
|
+
fail "installer removes legacy hook"
|
|
104
|
+
fi
|
|
105
|
+
pass "installer legacy hook cleanup"
|
|
106
|
+
|
|
107
|
+
printf 'e2e passed\n'
|