@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.
@@ -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'