@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.
@@ -35,11 +35,27 @@ SAFEDEPS_MANIFEST_FILES=(
35
35
  umask 077
36
36
  mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
37
37
 
38
+ # Observable record when the effect gate cannot run (AGENTS.md: no silent fallback).
39
+ # The install already happened by PostToolUse, so we cannot block it; what we can
40
+ # guarantee is that an un-runnable gate is recorded as UNVERIFIED, never a silent pass.
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
+
38
45
  if ! command -v jq >/dev/null 2>&1; then
39
- echo "safedeps: jq is not installed; skipping verify hook." >&2
46
+ log_advisory "post-verify UNVERIFIED: jq missing could not verify the install closure. Install jq to restore the effect gate."
47
+ echo "safedeps: jq is not installed — the post-install effect gate could not run; this install is UNVERIFIED (logged to advisory.log)." >&2
40
48
  exit 0
41
49
  fi
42
50
 
51
+ SAFEDEPS_REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
52
+ # shellcheck source=../lib/ledger/ledger.sh
53
+ source "${SAFEDEPS_REPO_DIR}/lib/ledger/ledger.sh"
54
+ # shellcheck source=../lib/providers/providers.sh
55
+ source "${SAFEDEPS_REPO_DIR}/lib/providers/providers.sh"
56
+ # shellcheck source=../lib/npm/closure.sh
57
+ source "${SAFEDEPS_REPO_DIR}/lib/npm/closure.sh"
58
+
43
59
  acquire_state_lock() {
44
60
  local attempts=0
45
61
 
@@ -47,8 +63,10 @@ acquire_state_lock() {
47
63
  # Detect stale locks left by SIGKILL/OOM (V-005)
48
64
  if [[ -d "${STATE_LOCK_DIR}" ]]; then
49
65
  local lock_mtime=""
50
- if lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null) || \
51
- lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null); then
66
+ # GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
67
+ # means --file-system and would not yield an mtime.
68
+ if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
69
+ lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
52
70
  local now
53
71
  now=$(date +%s)
54
72
  if [[ $(( now - lock_mtime )) -gt 60 ]]; then
@@ -60,8 +78,11 @@ acquire_state_lock() {
60
78
  fi
61
79
 
62
80
  attempts=$((attempts + 1))
63
- if [[ ${attempts} -ge 100 ]]; then
64
- echo "safedeps: could not acquire state lock; skipping verify hook." >&2
81
+ if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
82
+ # Install already ran; another safedeps run holds the lock. Record UNVERIFIED
83
+ # rather than silently passing — the other run, or a re-check, owns verification.
84
+ log_advisory "post-verify UNVERIFIED: state lock unavailable (another safedeps run active) — install not verified by this run."
85
+ echo "safedeps: could not acquire state lock; this install is UNVERIFIED by this run (logged to advisory.log)." >&2
65
86
  exit 0
66
87
  fi
67
88
  sleep 0.1
@@ -75,10 +96,16 @@ release_state_lock() {
75
96
  write_state_file() {
76
97
  local target_path="$1"
77
98
  local value="$2"
78
- local temp_path="${target_path}.$$"
79
-
99
+ local target_dir
100
+ local target_base
101
+ local temp_path
102
+
103
+ target_dir=$(dirname "${target_path}")
104
+ target_base=$(basename "${target_path}")
105
+ mkdir -p "${target_dir}" || return 1
106
+ temp_path=$(mktemp "${target_dir}/.${target_base}.XXXXXX") || return 1
80
107
  printf '%s\n' "${value}" > "${temp_path}"
81
- mv "${temp_path}" "${target_path}"
108
+ mv -f "${temp_path}" "${target_path}"
82
109
  }
83
110
 
84
111
  compute_dir_hash() {
@@ -214,7 +241,7 @@ collect_protected_snapshot_ids() {
214
241
  local already_seen="false"
215
242
  local seen_id
216
243
 
217
- for seen_id in "${seen[@]}"; do
244
+ for seen_id in "${seen[@]+${seen[@]}}"; do
218
245
  if [[ "${seen_id}" == "${snapshot_id}" ]]; then
219
246
  already_seen="true"
220
247
  break
@@ -305,6 +332,41 @@ restore_node_modules() {
305
332
  ROLLBACK_WARNINGS+=("node_modules reinstall failed; review the project manually")
306
333
  }
307
334
 
335
+ run_verified_npm_rebuild_if_injected() {
336
+ local injected
337
+
338
+ injected=$(jq -r '.ignore_scripts_injected == true' "${META_FILE}" 2>/dev/null || printf 'false')
339
+ [[ "${injected}" == "true" ]] || return 0
340
+
341
+ if ! command -v npm >/dev/null 2>&1; then
342
+ ROLLBACK_WARNINGS+=("npm is not installed; npm rebuild was not run after verified inert install")
343
+ return 0
344
+ fi
345
+
346
+ if (cd "${PROJECT_DIR}" && npm rebuild >/dev/null 2>&1); then
347
+ return 0
348
+ fi
349
+
350
+ ROLLBACK_WARNINGS+=("npm rebuild failed after verified inert install; lifecycle scripts may need manual review")
351
+ }
352
+
353
+ emit_confirm_warnings_if_any() {
354
+ local warning_str
355
+
356
+ [[ ${#ROLLBACK_WARNINGS[@]} -gt 0 ]] || return 0
357
+
358
+ warning_str=$(printf '%s; ' "${ROLLBACK_WARNINGS[@]}")
359
+ cat >> "${GUARD_DIR}/reorg.log" << LOG_EOF
360
+ [$(date -u +"%Y-%m-%dT%H:%M:%SZ")] CONFIRM warnings
361
+ Snapshot: ${SNAPSHOT_ID}
362
+ Project: ${PROJECT_DIR}
363
+ Warnings: ${warning_str%%; }
364
+ LOG_EOF
365
+
366
+ jq -nc --arg warnings "${warning_str%%; }" \
367
+ '{systemMessage: ("safedeps: verified install completed, but npm rebuild warning(s) were recorded:\n" + $warnings)}'
368
+ }
369
+
308
370
  # Read tool input from stdin
309
371
  INPUT=$(cat)
310
372
 
@@ -358,6 +420,32 @@ SUSPICIOUS=false
358
420
  REASONS=()
359
421
  ROLLBACK_WARNINGS=()
360
422
 
423
+ redact_install_script_content() {
424
+ local script_content="$1"
425
+ local flattened
426
+ local byte_count
427
+ local digest
428
+ local suffix=""
429
+
430
+ flattened=$(printf '%s' "${script_content}" | tr '\r\n\t' ' ' | cut -c 1-160)
431
+ byte_count=$(printf '%s' "${script_content}" | wc -c | tr -d ' ')
432
+ if command -v shasum >/dev/null 2>&1; then
433
+ digest=$(printf '%s' "${script_content}" | shasum -a 256 | cut -d' ' -f1)
434
+ elif command -v sha256sum >/dev/null 2>&1; then
435
+ digest=$(printf '%s' "${script_content}" | sha256sum | cut -d' ' -f1)
436
+ else
437
+ digest="unavailable"
438
+ fi
439
+ if [[ "${byte_count}" -gt 160 ]]; then
440
+ suffix="..."
441
+ fi
442
+ printf '[redacted install script sha256=%s bytes=%s preview=%s%s]' \
443
+ "${digest}" \
444
+ "${byte_count}" \
445
+ "${flattened}" \
446
+ "${suffix}"
447
+ }
448
+
361
449
  # Function: check for suspicious postinstall scripts in new/changed dependencies
362
450
  check_postinstall_scripts() {
363
451
  local pkg_json="${PROJECT_DIR}/package.json"
@@ -411,17 +499,17 @@ check_postinstall_scripts() {
411
499
  # Check for network calls in install scripts
412
500
  if echo "${script_content}" | grep -qEi '(curl|wget|fetch|http|https|net\.|socket|dns)'; then
413
501
  SUSPICIOUS=true
414
- REASONS+=("Package '${pkg_name}' has install script with network access: ${script_content}")
502
+ REASONS+=("Package '${pkg_name}' has install script with network access: $(redact_install_script_content "${script_content}")")
415
503
  fi
416
504
 
417
505
  # Check for eval/exec in install scripts
418
506
  if echo "${script_content}" | grep -qEi '(eval|exec|spawn|child_process|Function\()'; then
419
507
  SUSPICIOUS=true
420
- REASONS+=("Package '${pkg_name}' has install script with code execution: ${script_content}")
508
+ REASONS+=("Package '${pkg_name}' has install script with code execution: $(redact_install_script_content "${script_content}")")
421
509
  fi
422
510
 
423
511
  # Check for filesystem access outside project
424
- if echo "${script_content}" | grep -qEi '(\/etc\/|\/home\/|~\/|\$HOME|\.ssh|\.env|\.aws|credentials)'; then
512
+ if echo "${script_content}" | grep -qEi '(\/etc\/|\/home\/|~\/|\$HOME|\.ssh|\.env|\.aws|credentials|~\/\.safedeps|\$HOME\/\.safedeps|\.safedeps\/|SAFEDEPS_HOME)'; then
425
513
  SUSPICIOUS=true
426
514
  REASONS+=("Package '${pkg_name}' has install script accessing sensitive paths")
427
515
  fi
@@ -507,7 +595,74 @@ check_binaries() {
507
595
  fi
508
596
  }
509
597
 
598
+ check_npm_effect_closure() {
599
+ local lockfile="${PROJECT_DIR}/package-lock.json"
600
+ local closure_file
601
+ local provider_file
602
+ local miss_file
603
+ local package_name
604
+ local version
605
+ local miss_count
606
+ local vulnerable_summary
607
+ local kev_summary
608
+
609
+ [[ -f "${lockfile}" ]] || return 0
610
+
611
+ closure_file=$(mktemp "${TMPDIR:-/tmp}/safedeps-post-closure.XXXXXX") || return
612
+ provider_file=$(mktemp "${TMPDIR:-/tmp}/safedeps-post-provider.XXXXXX") || {
613
+ rm -f "${closure_file}"
614
+ return
615
+ }
616
+ miss_file=$(mktemp "${TMPDIR:-/tmp}/safedeps-post-miss.XXXXXX") || {
617
+ rm -f "${closure_file}" "${provider_file}"
618
+ return
619
+ }
620
+ : > "${miss_file}"
621
+
622
+ if ! safedeps_npm_lock_closure "${lockfile}" > "${closure_file}"; then
623
+ SUSPICIOUS=true
624
+ REASONS+=("npm package-lock closure could not be parsed")
625
+ rm -f "${closure_file}" "${provider_file}" "${miss_file}"
626
+ return
627
+ fi
628
+
629
+ while IFS=$'\t' read -r package_name version; do
630
+ [[ -n "${package_name}" && -n "${version}" ]] || continue
631
+ if ! safedeps_ledger_effect_check "npm" "${package_name}" "${version}" >/dev/null 2>&1; then
632
+ printf '%s@%s\n' "${package_name}" "${version}" >> "${miss_file}"
633
+ fi
634
+ done < <(jq -r '.[] | [.package, (.version | tostring)] | @tsv' "${closure_file}")
635
+
636
+ miss_count=$(wc -l < "${miss_file}" | tr -d ' ')
637
+ if [[ "${miss_count}" -gt 0 ]]; then
638
+ SUSPICIOUS=true
639
+ REASONS+=("npm closure contains ${miss_count} unapproved package(s): $(head -20 "${miss_file}" | paste -sd ', ' -)")
640
+ fi
641
+
642
+ if ! safedeps_providers_query_batch "npm" "${closure_file}" > "${provider_file}"; then
643
+ SUSPICIOUS=true
644
+ REASONS+=("npm closure OSV batch verification failed; fail-closed")
645
+ rm -f "${closure_file}" "${provider_file}" "${miss_file}"
646
+ return
647
+ fi
648
+
649
+ kev_summary=$(jq -r '[.[] | select(.status == "hard_block") | "\(.package)@\(.version)"] | join(", ")' "${provider_file}")
650
+ if [[ -n "${kev_summary}" ]]; then
651
+ SUSPICIOUS=true
652
+ REASONS+=("npm closure contains KEV-blocked package(s): ${kev_summary}")
653
+ fi
654
+
655
+ vulnerable_summary=$(jq -r '[.[] | select(.status == "vulnerable") | "\(.package)@\(.version)"] | join(", ")' "${provider_file}")
656
+ if [[ -n "${vulnerable_summary}" ]]; then
657
+ SUSPICIOUS=true
658
+ REASONS+=("npm closure contains vulnerable package(s): ${vulnerable_summary}")
659
+ fi
660
+
661
+ rm -f "${closure_file}" "${provider_file}" "${miss_file}"
662
+ }
663
+
510
664
  # Run all checks
665
+ check_npm_effect_closure
511
666
  check_postinstall_scripts
512
667
  check_lockfile_diff
513
668
  check_binaries
@@ -572,13 +727,28 @@ if [[ "${SUSPICIOUS}" == "true" ]]; then
572
727
  Rollback warnings: ${WARNING_STR%%; }
573
728
  LOG_EOF
574
729
 
575
- cat << EOF
576
- {"systemMessage": "safedeps: 의심스러운 패키지 변경 감지, 마지막으로 confirmed 된 안전 스냅샷으로 롤백했습니다.\n\n감지된 문제:\n${REASON_STR%%; }\n\n롤백 기준 스냅샷: ${ROLLBACK_SNAPSHOT_ID}\n롤백된 파일: ${ROLLED_BACK_STR%, }\n${WARNING_STR:+\n추가 경고:\n${WARNING_STR%%; }}\n\n상세 로그: ${GUARD_DIR}/reorg.log"}
577
- EOF
730
+ jq -nc \
731
+ --arg reasons "${REASON_STR%%; }" \
732
+ --arg rollback_snapshot "${ROLLBACK_SNAPSHOT_ID}" \
733
+ --arg rolled_back "${ROLLED_BACK_STR%, }" \
734
+ --arg warnings "${WARNING_STR%%; }" \
735
+ --arg log_path "${GUARD_DIR}/reorg.log" \
736
+ '{
737
+ systemMessage: (
738
+ "safedeps: 의심스러운 패키지 변경 감지, 마지막으로 confirmed 된 안전 스냅샷으로 롤백했습니다.\n\n" +
739
+ "감지된 문제:\n" + $reasons + "\n\n" +
740
+ "롤백 기준 스냅샷: " + $rollback_snapshot + "\n" +
741
+ "롤백된 파일: " + $rolled_back +
742
+ (if $warnings == "" then "" else "\n\n추가 경고:\n" + $warnings end) +
743
+ "\n\n상세 로그: " + $log_path
744
+ )
745
+ }'
578
746
  exit 0
579
747
  fi
580
748
 
749
+ run_verified_npm_rebuild_if_injected
581
750
  confirm_snapshot "${SNAPSHOT_ID}" "${DIR_HASH}"
582
751
  cleanup_old_snapshots
752
+ emit_confirm_warnings_if_any
583
753
 
584
754
  exit 0