@aldegad/safedeps 2.1.0 → 2.2.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,252 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT=""
5
+ STRICT=0
6
+ NO_RUN=0
7
+
8
+ usage() {
9
+ cat <<'EOF'
10
+ Usage: run-release-gates.sh [--root <repo>] [--strict] [--no-run]
11
+
12
+ Runs release-time security gates for the current repository tree.
13
+ EOF
14
+ }
15
+
16
+ while [ $# -gt 0 ]; do
17
+ case "$1" in
18
+ --root)
19
+ ROOT="${2:-}"
20
+ shift 2
21
+ ;;
22
+ --strict)
23
+ STRICT=1
24
+ shift
25
+ ;;
26
+ --no-run)
27
+ NO_RUN=1
28
+ shift
29
+ ;;
30
+ -h|--help)
31
+ usage
32
+ exit 0
33
+ ;;
34
+ *)
35
+ usage >&2
36
+ exit 64
37
+ ;;
38
+ esac
39
+ done
40
+
41
+ if [ -z "$ROOT" ]; then
42
+ ROOT="$(pwd)"
43
+ fi
44
+
45
+ if command -v realpath >/dev/null 2>&1; then
46
+ ROOT="$(realpath "$ROOT")"
47
+ else
48
+ ROOT="$(cd "$ROOT" && pwd)"
49
+ fi
50
+
51
+ if [ ! -d "$ROOT" ]; then
52
+ printf 'ERROR: repo root does not exist: %s\n' "$ROOT" >&2
53
+ exit 1
54
+ fi
55
+
56
+ cd "$ROOT"
57
+
58
+ FAILURES=0
59
+ WARNINGS=0
60
+ RAN=0
61
+
62
+ section() {
63
+ printf '\n== %s ==\n' "$1"
64
+ }
65
+
66
+ pass() {
67
+ printf 'PASS [%s] %s\n' "$1" "$2"
68
+ }
69
+
70
+ warn() {
71
+ WARNINGS=$((WARNINGS + 1))
72
+ printf 'WARN [%s] %s\n' "$1" "$2" >&2
73
+ }
74
+
75
+ fail() {
76
+ FAILURES=$((FAILURES + 1))
77
+ printf 'FAIL [%s] %s\n' "$1" "$2" >&2
78
+ }
79
+
80
+ strict_or_warn() {
81
+ if [ "$STRICT" -eq 1 ]; then
82
+ fail "$1" "$2"
83
+ else
84
+ warn "$1" "$2"
85
+ fi
86
+ }
87
+
88
+ run_cmd() {
89
+ local gate="$1"
90
+ local desc="$2"
91
+ shift 2
92
+
93
+ RAN=$((RAN + 1))
94
+ printf 'RUN [%s] %s\n' "$gate" "$desc"
95
+ printf 'CMD [%s] %s\n' "$gate" "$*"
96
+
97
+ if [ "$NO_RUN" -eq 1 ]; then
98
+ pass "$gate" "planned only (--no-run)"
99
+ return 0
100
+ fi
101
+
102
+ if "$@"; then
103
+ pass "$gate" "$desc"
104
+ else
105
+ fail "$gate" "$desc"
106
+ fi
107
+ }
108
+
109
+ has_file() {
110
+ [ -f "$1" ]
111
+ }
112
+
113
+ has_npm_script() {
114
+ local script_name="$1"
115
+ has_file package.json || return 1
116
+ command -v node >/dev/null 2>&1 || return 1
117
+ node -e '
118
+ const fs = require("node:fs");
119
+ const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
120
+ process.exit(pkg.scripts && Object.prototype.hasOwnProperty.call(pkg.scripts, process.argv[1]) ? 0 : 1);
121
+ ' "$script_name"
122
+ }
123
+
124
+ run_npm_script_if_present() {
125
+ local script_name="$1"
126
+ local gate="$2"
127
+ if has_npm_script "$script_name"; then
128
+ run_cmd "$gate" "npm run $script_name" npm run "$script_name"
129
+ return 0
130
+ fi
131
+ return 1
132
+ }
133
+
134
+ detect_python_surface() {
135
+ find . -maxdepth 3 \
136
+ \( -name 'requirements*.txt' -o -name 'pyproject.toml' -o -name 'poetry.lock' -o -name 'uv.lock' -o -name 'Pipfile.lock' \) \
137
+ -not -path './node_modules/*' \
138
+ -not -path './.git/*' \
139
+ -print
140
+ }
141
+
142
+ detect_requirements_files() {
143
+ find . -maxdepth 3 \
144
+ -name 'requirements*.txt' \
145
+ -not -path './node_modules/*' \
146
+ -not -path './.git/*' \
147
+ -print | sort
148
+ }
149
+
150
+ hook_file_mentions_reorg_guard() {
151
+ local file="$1"
152
+ [ -f "$file" ] || return 1
153
+ grep -q 'safedeps' "$file"
154
+ }
155
+
156
+ safedeps_install_guard_present() {
157
+ [ -d "$HOME/.claude/skills/safedeps" ] && return 0
158
+ [ -d "$HOME/.codex/skills/safedeps" ] && return 0
159
+ hook_file_mentions_reorg_guard "$HOME/.claude/settings.json" && return 0
160
+ hook_file_mentions_reorg_guard "$HOME/.codex/hooks.json" && return 0
161
+ return 1
162
+ }
163
+
164
+ section "repo"
165
+ printf 'root: %s\n' "$ROOT"
166
+ if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
167
+ pass repo "inside git worktree"
168
+ else
169
+ strict_or_warn repo "not inside a git worktree"
170
+ fi
171
+
172
+ if [ -f docs/security-release-gates.md ] || [ -f SECURITY.md ]; then
173
+ pass repo "release/security documentation present"
174
+ else
175
+ warn repo "no docs/security-release-gates.md or SECURITY.md"
176
+ fi
177
+
178
+ section "secrets"
179
+ if run_npm_script_if_present "security:hooks:check" "secrets"; then
180
+ :
181
+ fi
182
+ if run_npm_script_if_present "security:scan:worktree" "secrets"; then
183
+ :
184
+ elif [ -x scripts/security/run-gitleaks.sh ]; then
185
+ run_cmd secrets "repo gitleaks wrapper" bash scripts/security/run-gitleaks.sh --worktree
186
+ elif command -v gitleaks >/dev/null 2>&1 && { [ -f .gitleaks.toml ] || [ -f gitleaks.toml ]; }; then
187
+ config=".gitleaks.toml"
188
+ [ -f "$config" ] || config="gitleaks.toml"
189
+ run_cmd secrets "gitleaks dir scan" gitleaks dir --no-banner --redact --verbose --config "$config" .
190
+ else
191
+ strict_or_warn secrets "no gitleaks gate detected"
192
+ fi
193
+
194
+ section "node"
195
+ if has_file package.json; then
196
+ pass node "package.json detected"
197
+ if run_npm_script_if_present "security:audit" "node"; then
198
+ :
199
+ elif has_file package-lock.json || has_file npm-shrinkwrap.json; then
200
+ run_cmd node "npm audit --audit-level=moderate" npm audit --audit-level=moderate
201
+ else
202
+ strict_or_warn node "package.json exists but no npm lockfile/audit script was detected"
203
+ fi
204
+
205
+ if safedeps_install_guard_present; then
206
+ pass install-guard "safedeps appears installed/configured"
207
+ elif [ "$STRICT" -eq 1 ] || [ "${SECURITY_RELEASE_GATES_REQUIRE_INSTALL_GUARD:-0}" = "1" ]; then
208
+ fail install-guard "npm project has no detectable safedeps install-time guard"
209
+ else
210
+ warn install-guard "safedeps not detected; release gate can continue, install-time guard is separate"
211
+ fi
212
+ else
213
+ pass node "no package.json detected"
214
+ fi
215
+
216
+ section "python"
217
+ PYTHON_SURFACE="$(detect_python_surface || true)"
218
+ if [ -z "$PYTHON_SURFACE" ]; then
219
+ pass python "no Python dependency surface detected"
220
+ elif [ -n "${SECURITY_RELEASE_GATES_PYTHON_AUDIT_COMMAND:-}" ]; then
221
+ run_cmd python "custom Python audit command" bash -lc "$SECURITY_RELEASE_GATES_PYTHON_AUDIT_COMMAND"
222
+ elif command -v pip-audit >/dev/null 2>&1; then
223
+ REQUIREMENTS="$(detect_requirements_files || true)"
224
+ if [ -n "$REQUIREMENTS" ]; then
225
+ while IFS= read -r requirements_file; do
226
+ [ -n "$requirements_file" ] || continue
227
+ run_cmd python "pip-audit $requirements_file" pip-audit -r "$requirements_file"
228
+ done <<EOF_REQ
229
+ $REQUIREMENTS
230
+ EOF_REQ
231
+ else
232
+ strict_or_warn python "Python lock/project files detected, but no requirements*.txt or repo-provided Python audit command exists"
233
+ fi
234
+ else
235
+ strict_or_warn python "Python dependency files detected, but pip-audit is not installed and no custom audit command was provided"
236
+ fi
237
+
238
+ section "ci"
239
+ if find .github/workflows -maxdepth 1 -type f 2>/dev/null | xargs grep -E 'security:|gitleaks|pip-audit|npm audit' >/dev/null 2>&1; then
240
+ pass ci "workflow appears to run security gates"
241
+ else
242
+ warn ci "no obvious GitHub security gate workflow detected"
243
+ fi
244
+
245
+ section "summary"
246
+ printf 'gates_run=%s warnings=%s failures=%s strict=%s no_run=%s\n' "$RAN" "$WARNINGS" "$FAILURES" "$STRICT" "$NO_RUN"
247
+
248
+ if [ "$FAILURES" -gt 0 ]; then
249
+ exit 1
250
+ fi
251
+
252
+ exit 0
@@ -40,6 +40,14 @@ if ! command -v jq >/dev/null 2>&1; then
40
40
  exit 0
41
41
  fi
42
42
 
43
+ SAFEDEPS_REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
44
+ # shellcheck source=../lib/ledger/ledger.sh
45
+ source "${SAFEDEPS_REPO_DIR}/lib/ledger/ledger.sh"
46
+ # shellcheck source=../lib/providers/providers.sh
47
+ source "${SAFEDEPS_REPO_DIR}/lib/providers/providers.sh"
48
+ # shellcheck source=../lib/npm/closure.sh
49
+ source "${SAFEDEPS_REPO_DIR}/lib/npm/closure.sh"
50
+
43
51
  acquire_state_lock() {
44
52
  local attempts=0
45
53
 
@@ -75,10 +83,16 @@ release_state_lock() {
75
83
  write_state_file() {
76
84
  local target_path="$1"
77
85
  local value="$2"
78
- local temp_path="${target_path}.$$"
79
-
86
+ local target_dir
87
+ local target_base
88
+ local temp_path
89
+
90
+ target_dir=$(dirname "${target_path}")
91
+ target_base=$(basename "${target_path}")
92
+ mkdir -p "${target_dir}" || return 1
93
+ temp_path=$(mktemp "${target_dir}/.${target_base}.XXXXXX") || return 1
80
94
  printf '%s\n' "${value}" > "${temp_path}"
81
- mv "${temp_path}" "${target_path}"
95
+ mv -f "${temp_path}" "${target_path}"
82
96
  }
83
97
 
84
98
  compute_dir_hash() {
@@ -214,7 +228,7 @@ collect_protected_snapshot_ids() {
214
228
  local already_seen="false"
215
229
  local seen_id
216
230
 
217
- for seen_id in "${seen[@]}"; do
231
+ for seen_id in "${seen[@]+${seen[@]}}"; do
218
232
  if [[ "${seen_id}" == "${snapshot_id}" ]]; then
219
233
  already_seen="true"
220
234
  break
@@ -305,6 +319,41 @@ restore_node_modules() {
305
319
  ROLLBACK_WARNINGS+=("node_modules reinstall failed; review the project manually")
306
320
  }
307
321
 
322
+ run_verified_npm_rebuild_if_injected() {
323
+ local injected
324
+
325
+ injected=$(jq -r '.ignore_scripts_injected == true' "${META_FILE}" 2>/dev/null || printf 'false')
326
+ [[ "${injected}" == "true" ]] || return 0
327
+
328
+ if ! command -v npm >/dev/null 2>&1; then
329
+ ROLLBACK_WARNINGS+=("npm is not installed; npm rebuild was not run after verified inert install")
330
+ return 0
331
+ fi
332
+
333
+ if (cd "${PROJECT_DIR}" && npm rebuild >/dev/null 2>&1); then
334
+ return 0
335
+ fi
336
+
337
+ ROLLBACK_WARNINGS+=("npm rebuild failed after verified inert install; lifecycle scripts may need manual review")
338
+ }
339
+
340
+ emit_confirm_warnings_if_any() {
341
+ local warning_str
342
+
343
+ [[ ${#ROLLBACK_WARNINGS[@]} -gt 0 ]] || return 0
344
+
345
+ warning_str=$(printf '%s; ' "${ROLLBACK_WARNINGS[@]}")
346
+ cat >> "${GUARD_DIR}/reorg.log" << LOG_EOF
347
+ [$(date -u +"%Y-%m-%dT%H:%M:%SZ")] CONFIRM warnings
348
+ Snapshot: ${SNAPSHOT_ID}
349
+ Project: ${PROJECT_DIR}
350
+ Warnings: ${warning_str%%; }
351
+ LOG_EOF
352
+
353
+ jq -nc --arg warnings "${warning_str%%; }" \
354
+ '{systemMessage: ("safedeps: verified install completed, but npm rebuild warning(s) were recorded:\n" + $warnings)}'
355
+ }
356
+
308
357
  # Read tool input from stdin
309
358
  INPUT=$(cat)
310
359
 
@@ -358,6 +407,32 @@ SUSPICIOUS=false
358
407
  REASONS=()
359
408
  ROLLBACK_WARNINGS=()
360
409
 
410
+ redact_install_script_content() {
411
+ local script_content="$1"
412
+ local flattened
413
+ local byte_count
414
+ local digest
415
+ local suffix=""
416
+
417
+ flattened=$(printf '%s' "${script_content}" | tr '\r\n\t' ' ' | cut -c 1-160)
418
+ byte_count=$(printf '%s' "${script_content}" | wc -c | tr -d ' ')
419
+ if command -v shasum >/dev/null 2>&1; then
420
+ digest=$(printf '%s' "${script_content}" | shasum -a 256 | cut -d' ' -f1)
421
+ elif command -v sha256sum >/dev/null 2>&1; then
422
+ digest=$(printf '%s' "${script_content}" | sha256sum | cut -d' ' -f1)
423
+ else
424
+ digest="unavailable"
425
+ fi
426
+ if [[ "${byte_count}" -gt 160 ]]; then
427
+ suffix="..."
428
+ fi
429
+ printf '[redacted install script sha256=%s bytes=%s preview=%s%s]' \
430
+ "${digest}" \
431
+ "${byte_count}" \
432
+ "${flattened}" \
433
+ "${suffix}"
434
+ }
435
+
361
436
  # Function: check for suspicious postinstall scripts in new/changed dependencies
362
437
  check_postinstall_scripts() {
363
438
  local pkg_json="${PROJECT_DIR}/package.json"
@@ -411,17 +486,17 @@ check_postinstall_scripts() {
411
486
  # Check for network calls in install scripts
412
487
  if echo "${script_content}" | grep -qEi '(curl|wget|fetch|http|https|net\.|socket|dns)'; then
413
488
  SUSPICIOUS=true
414
- REASONS+=("Package '${pkg_name}' has install script with network access: ${script_content}")
489
+ REASONS+=("Package '${pkg_name}' has install script with network access: $(redact_install_script_content "${script_content}")")
415
490
  fi
416
491
 
417
492
  # Check for eval/exec in install scripts
418
493
  if echo "${script_content}" | grep -qEi '(eval|exec|spawn|child_process|Function\()'; then
419
494
  SUSPICIOUS=true
420
- REASONS+=("Package '${pkg_name}' has install script with code execution: ${script_content}")
495
+ REASONS+=("Package '${pkg_name}' has install script with code execution: $(redact_install_script_content "${script_content}")")
421
496
  fi
422
497
 
423
498
  # Check for filesystem access outside project
424
- if echo "${script_content}" | grep -qEi '(\/etc\/|\/home\/|~\/|\$HOME|\.ssh|\.env|\.aws|credentials)'; then
499
+ if echo "${script_content}" | grep -qEi '(\/etc\/|\/home\/|~\/|\$HOME|\.ssh|\.env|\.aws|credentials|~\/\.safedeps|\$HOME\/\.safedeps|\.safedeps\/|SAFEDEPS_HOME)'; then
425
500
  SUSPICIOUS=true
426
501
  REASONS+=("Package '${pkg_name}' has install script accessing sensitive paths")
427
502
  fi
@@ -507,7 +582,74 @@ check_binaries() {
507
582
  fi
508
583
  }
509
584
 
585
+ check_npm_effect_closure() {
586
+ local lockfile="${PROJECT_DIR}/package-lock.json"
587
+ local closure_file
588
+ local provider_file
589
+ local miss_file
590
+ local package_name
591
+ local version
592
+ local miss_count
593
+ local vulnerable_summary
594
+ local kev_summary
595
+
596
+ [[ -f "${lockfile}" ]] || return 0
597
+
598
+ closure_file=$(mktemp "${TMPDIR:-/tmp}/safedeps-post-closure.XXXXXX") || return
599
+ provider_file=$(mktemp "${TMPDIR:-/tmp}/safedeps-post-provider.XXXXXX") || {
600
+ rm -f "${closure_file}"
601
+ return
602
+ }
603
+ miss_file=$(mktemp "${TMPDIR:-/tmp}/safedeps-post-miss.XXXXXX") || {
604
+ rm -f "${closure_file}" "${provider_file}"
605
+ return
606
+ }
607
+ : > "${miss_file}"
608
+
609
+ if ! safedeps_npm_lock_closure "${lockfile}" > "${closure_file}"; then
610
+ SUSPICIOUS=true
611
+ REASONS+=("npm package-lock closure could not be parsed")
612
+ rm -f "${closure_file}" "${provider_file}" "${miss_file}"
613
+ return
614
+ fi
615
+
616
+ while IFS=$'\t' read -r package_name version; do
617
+ [[ -n "${package_name}" && -n "${version}" ]] || continue
618
+ if ! safedeps_ledger_effect_check "npm" "${package_name}" "${version}" >/dev/null 2>&1; then
619
+ printf '%s@%s\n' "${package_name}" "${version}" >> "${miss_file}"
620
+ fi
621
+ done < <(jq -r '.[] | [.package, (.version | tostring)] | @tsv' "${closure_file}")
622
+
623
+ miss_count=$(wc -l < "${miss_file}" | tr -d ' ')
624
+ if [[ "${miss_count}" -gt 0 ]]; then
625
+ SUSPICIOUS=true
626
+ REASONS+=("npm closure contains ${miss_count} unapproved package(s): $(head -20 "${miss_file}" | paste -sd ', ' -)")
627
+ fi
628
+
629
+ if ! safedeps_providers_query_batch "npm" "${closure_file}" > "${provider_file}"; then
630
+ SUSPICIOUS=true
631
+ REASONS+=("npm closure OSV batch verification failed; fail-closed")
632
+ rm -f "${closure_file}" "${provider_file}" "${miss_file}"
633
+ return
634
+ fi
635
+
636
+ kev_summary=$(jq -r '[.[] | select(.status == "hard_block") | "\(.package)@\(.version)"] | join(", ")' "${provider_file}")
637
+ if [[ -n "${kev_summary}" ]]; then
638
+ SUSPICIOUS=true
639
+ REASONS+=("npm closure contains KEV-blocked package(s): ${kev_summary}")
640
+ fi
641
+
642
+ vulnerable_summary=$(jq -r '[.[] | select(.status == "vulnerable") | "\(.package)@\(.version)"] | join(", ")' "${provider_file}")
643
+ if [[ -n "${vulnerable_summary}" ]]; then
644
+ SUSPICIOUS=true
645
+ REASONS+=("npm closure contains vulnerable package(s): ${vulnerable_summary}")
646
+ fi
647
+
648
+ rm -f "${closure_file}" "${provider_file}" "${miss_file}"
649
+ }
650
+
510
651
  # Run all checks
652
+ check_npm_effect_closure
511
653
  check_postinstall_scripts
512
654
  check_lockfile_diff
513
655
  check_binaries
@@ -572,13 +714,28 @@ if [[ "${SUSPICIOUS}" == "true" ]]; then
572
714
  Rollback warnings: ${WARNING_STR%%; }
573
715
  LOG_EOF
574
716
 
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
717
+ jq -nc \
718
+ --arg reasons "${REASON_STR%%; }" \
719
+ --arg rollback_snapshot "${ROLLBACK_SNAPSHOT_ID}" \
720
+ --arg rolled_back "${ROLLED_BACK_STR%, }" \
721
+ --arg warnings "${WARNING_STR%%; }" \
722
+ --arg log_path "${GUARD_DIR}/reorg.log" \
723
+ '{
724
+ systemMessage: (
725
+ "safedeps: 의심스러운 패키지 변경 감지, 마지막으로 confirmed 된 안전 스냅샷으로 롤백했습니다.\n\n" +
726
+ "감지된 문제:\n" + $reasons + "\n\n" +
727
+ "롤백 기준 스냅샷: " + $rollback_snapshot + "\n" +
728
+ "롤백된 파일: " + $rolled_back +
729
+ (if $warnings == "" then "" else "\n\n추가 경고:\n" + $warnings end) +
730
+ "\n\n상세 로그: " + $log_path
731
+ )
732
+ }'
578
733
  exit 0
579
734
  fi
580
735
 
736
+ run_verified_npm_rebuild_if_injected
581
737
  confirm_snapshot "${SNAPSHOT_ID}" "${DIR_HASH}"
582
738
  cleanup_old_snapshots
739
+ emit_confirm_warnings_if_any
583
740
 
584
741
  exit 0