@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,584 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# safedeps: PostToolUse hook
|
|
3
|
+
# Verifies dependency file changes after install commands and performs reorg (rollback) if suspicious
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
GUARD_DIR="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
|
|
8
|
+
SNAPSHOT_DIR="${GUARD_DIR}/snapshots"
|
|
9
|
+
STATE_LOCK_DIR="${GUARD_DIR}/state.lock"
|
|
10
|
+
|
|
11
|
+
SAFEDEPS_LOCK_FILES=(
|
|
12
|
+
"package-lock.json"
|
|
13
|
+
"pnpm-lock.yaml"
|
|
14
|
+
"yarn.lock"
|
|
15
|
+
"poetry.lock"
|
|
16
|
+
"uv.lock"
|
|
17
|
+
"Pipfile.lock"
|
|
18
|
+
"requirements.txt"
|
|
19
|
+
"Cargo.lock"
|
|
20
|
+
"go.sum"
|
|
21
|
+
"Gemfile.lock"
|
|
22
|
+
"packages.lock.json"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
SAFEDEPS_MANIFEST_FILES=(
|
|
26
|
+
"package.json"
|
|
27
|
+
"pyproject.toml"
|
|
28
|
+
"Pipfile"
|
|
29
|
+
"Cargo.toml"
|
|
30
|
+
"go.mod"
|
|
31
|
+
"Gemfile"
|
|
32
|
+
"pom.xml"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
umask 077
|
|
36
|
+
mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
|
|
37
|
+
|
|
38
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
39
|
+
echo "safedeps: jq is not installed; skipping verify hook." >&2
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
acquire_state_lock() {
|
|
44
|
+
local attempts=0
|
|
45
|
+
|
|
46
|
+
while ! mkdir "${STATE_LOCK_DIR}" 2>/dev/null; do
|
|
47
|
+
# Detect stale locks left by SIGKILL/OOM (V-005)
|
|
48
|
+
if [[ -d "${STATE_LOCK_DIR}" ]]; then
|
|
49
|
+
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
|
|
52
|
+
local now
|
|
53
|
+
now=$(date +%s)
|
|
54
|
+
if [[ $(( now - lock_mtime )) -gt 60 ]]; then
|
|
55
|
+
echo "safedeps: removing stale lock ($(( now - lock_mtime ))s old)." >&2
|
|
56
|
+
rmdir "${STATE_LOCK_DIR}" 2>/dev/null || true
|
|
57
|
+
continue
|
|
58
|
+
fi
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
attempts=$((attempts + 1))
|
|
63
|
+
if [[ ${attempts} -ge 100 ]]; then
|
|
64
|
+
echo "safedeps: could not acquire state lock; skipping verify hook." >&2
|
|
65
|
+
exit 0
|
|
66
|
+
fi
|
|
67
|
+
sleep 0.1
|
|
68
|
+
done
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
release_state_lock() {
|
|
72
|
+
rmdir "${STATE_LOCK_DIR}" 2>/dev/null || true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
write_state_file() {
|
|
76
|
+
local target_path="$1"
|
|
77
|
+
local value="$2"
|
|
78
|
+
local temp_path="${target_path}.$$"
|
|
79
|
+
|
|
80
|
+
printf '%s\n' "${value}" > "${temp_path}"
|
|
81
|
+
mv "${temp_path}" "${target_path}"
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
compute_dir_hash() {
|
|
85
|
+
local input_dir="$1"
|
|
86
|
+
|
|
87
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
88
|
+
printf '%s' "${input_dir}" | md5sum | cut -d' ' -f1
|
|
89
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
90
|
+
md5 -q -s "${input_dir}"
|
|
91
|
+
else
|
|
92
|
+
printf '%s' "${input_dir}" | cksum | cut -d' ' -f1
|
|
93
|
+
fi
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
hash_file() {
|
|
97
|
+
local file_path="$1"
|
|
98
|
+
|
|
99
|
+
if command -v shasum >/dev/null 2>&1; then
|
|
100
|
+
shasum -a 256 "${file_path}" | cut -d' ' -f1
|
|
101
|
+
elif command -v sha256sum >/dev/null 2>&1; then
|
|
102
|
+
sha256sum "${file_path}" | cut -d' ' -f1
|
|
103
|
+
else
|
|
104
|
+
echo ""
|
|
105
|
+
fi
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
files_differ() {
|
|
109
|
+
local left_path="$1"
|
|
110
|
+
local right_path="$2"
|
|
111
|
+
local left_hash
|
|
112
|
+
local right_hash
|
|
113
|
+
|
|
114
|
+
if [[ ! -f "${left_path}" ]] && [[ ! -f "${right_path}" ]]; then
|
|
115
|
+
return 1
|
|
116
|
+
fi
|
|
117
|
+
|
|
118
|
+
if [[ ! -f "${left_path}" ]] || [[ ! -f "${right_path}" ]]; then
|
|
119
|
+
return 0
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
if command -v cmp >/dev/null 2>&1; then
|
|
123
|
+
! cmp -s "${left_path}" "${right_path}"
|
|
124
|
+
return
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
left_hash=$(hash_file "${left_path}")
|
|
128
|
+
right_hash=$(hash_file "${right_path}")
|
|
129
|
+
|
|
130
|
+
if [[ -n "${left_hash}" ]] && [[ -n "${right_hash}" ]]; then
|
|
131
|
+
[[ "${left_hash}" != "${right_hash}" ]]
|
|
132
|
+
return
|
|
133
|
+
fi
|
|
134
|
+
|
|
135
|
+
! diff -q "${left_path}" "${right_path}" >/dev/null 2>&1
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
monitored_files() {
|
|
139
|
+
local monitored_list="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_monitored_files.list"
|
|
140
|
+
local file_name
|
|
141
|
+
|
|
142
|
+
if [[ -f "${monitored_list}" ]]; then
|
|
143
|
+
sort -u "${monitored_list}"
|
|
144
|
+
return
|
|
145
|
+
fi
|
|
146
|
+
|
|
147
|
+
for file_name in "${SAFEDEPS_LOCK_FILES[@]}" "${SAFEDEPS_MANIFEST_FILES[@]}"; do
|
|
148
|
+
printf '%s\n' "${file_name}"
|
|
149
|
+
done
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
restore_monitored_file() {
|
|
153
|
+
local file_name="$1"
|
|
154
|
+
local rollback_snapshot_id="$2"
|
|
155
|
+
local snapshot_file="${SNAPSHOT_DIR}/${rollback_snapshot_id}_${file_name}"
|
|
156
|
+
local missing_marker="${SNAPSHOT_DIR}/${rollback_snapshot_id}_${file_name}.missing"
|
|
157
|
+
local current_missing_marker="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${file_name}.missing"
|
|
158
|
+
local current_file="${PROJECT_DIR}/${file_name}"
|
|
159
|
+
|
|
160
|
+
if [[ -f "${snapshot_file}" ]]; then
|
|
161
|
+
if files_differ "${snapshot_file}" "${current_file}"; then
|
|
162
|
+
cp "${snapshot_file}" "${current_file}"
|
|
163
|
+
ROLLED_BACK+=("${file_name}")
|
|
164
|
+
fi
|
|
165
|
+
return
|
|
166
|
+
fi
|
|
167
|
+
|
|
168
|
+
if { [[ -f "${missing_marker}" ]] || [[ -f "${current_missing_marker}" ]]; } && [[ -f "${current_file}" ]]; then
|
|
169
|
+
rm -f "${current_file}"
|
|
170
|
+
ROLLED_BACK+=("${file_name}")
|
|
171
|
+
fi
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
read_confirmed_snapshot() {
|
|
175
|
+
local confirmed_snapshot=""
|
|
176
|
+
local dir_hash="${1:-}"
|
|
177
|
+
|
|
178
|
+
acquire_state_lock
|
|
179
|
+
# Project-scoped confirmed file
|
|
180
|
+
if [[ -n "${dir_hash}" ]] && [[ -f "${GUARD_DIR}/confirmed_${dir_hash}" ]]; then
|
|
181
|
+
confirmed_snapshot=$(cat "${GUARD_DIR}/confirmed_${dir_hash}" 2>/dev/null || true)
|
|
182
|
+
elif [[ -f "${GUARD_DIR}/confirmed" ]]; then
|
|
183
|
+
# Legacy fallback
|
|
184
|
+
confirmed_snapshot=$(cat "${GUARD_DIR}/confirmed" 2>/dev/null || true)
|
|
185
|
+
fi
|
|
186
|
+
release_state_lock; STATE_LOCK_HELD=false
|
|
187
|
+
|
|
188
|
+
printf '%s' "${confirmed_snapshot}"
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
confirm_snapshot() {
|
|
192
|
+
local snapshot_id="$1"
|
|
193
|
+
local dir_hash="${2:-}"
|
|
194
|
+
|
|
195
|
+
acquire_state_lock; STATE_LOCK_HELD=true
|
|
196
|
+
if [[ -n "${dir_hash}" ]]; then
|
|
197
|
+
write_state_file "${GUARD_DIR}/confirmed_${dir_hash}" "${snapshot_id}"
|
|
198
|
+
else
|
|
199
|
+
write_state_file "${GUARD_DIR}/confirmed" "${snapshot_id}"
|
|
200
|
+
fi
|
|
201
|
+
release_state_lock; STATE_LOCK_HELD=false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
collect_protected_snapshot_ids() {
|
|
205
|
+
local dir_hash="${1:-}"
|
|
206
|
+
local snapshot_id
|
|
207
|
+
local parent_snapshot_id
|
|
208
|
+
local meta_file
|
|
209
|
+
local seen=()
|
|
210
|
+
|
|
211
|
+
snapshot_id=$(read_confirmed_snapshot "${dir_hash}")
|
|
212
|
+
|
|
213
|
+
while [[ -n "${snapshot_id}" ]]; do
|
|
214
|
+
local already_seen="false"
|
|
215
|
+
local seen_id
|
|
216
|
+
|
|
217
|
+
for seen_id in "${seen[@]}"; do
|
|
218
|
+
if [[ "${seen_id}" == "${snapshot_id}" ]]; then
|
|
219
|
+
already_seen="true"
|
|
220
|
+
break
|
|
221
|
+
fi
|
|
222
|
+
done
|
|
223
|
+
|
|
224
|
+
if [[ "${already_seen}" == "true" ]]; then
|
|
225
|
+
break
|
|
226
|
+
fi
|
|
227
|
+
|
|
228
|
+
seen+=("${snapshot_id}")
|
|
229
|
+
printf '%s\n' "${snapshot_id}"
|
|
230
|
+
|
|
231
|
+
meta_file="${SNAPSHOT_DIR}/${snapshot_id}_meta.json"
|
|
232
|
+
if [[ ! -f "${meta_file}" ]]; then
|
|
233
|
+
break
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
parent_snapshot_id=$(jq -r '.parent_snapshot_id // empty' "${meta_file}" 2>/dev/null || true)
|
|
237
|
+
snapshot_id="${parent_snapshot_id}"
|
|
238
|
+
done
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
snapshot_is_protected() {
|
|
242
|
+
local target_snapshot_id="$1"
|
|
243
|
+
shift
|
|
244
|
+
|
|
245
|
+
local protected_snapshot_id
|
|
246
|
+
for protected_snapshot_id in "$@"; do
|
|
247
|
+
if [[ "${protected_snapshot_id}" == "${target_snapshot_id}" ]]; then
|
|
248
|
+
return 0
|
|
249
|
+
fi
|
|
250
|
+
done
|
|
251
|
+
|
|
252
|
+
return 1
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
cleanup_old_snapshots() {
|
|
256
|
+
local protected_snapshot_ids=()
|
|
257
|
+
local protected_snapshot_id
|
|
258
|
+
local old_meta
|
|
259
|
+
local old_id
|
|
260
|
+
local removable_seen=0
|
|
261
|
+
|
|
262
|
+
while IFS= read -r protected_snapshot_id; do
|
|
263
|
+
if [[ -n "${protected_snapshot_id}" ]]; then
|
|
264
|
+
protected_snapshot_ids+=("${protected_snapshot_id}")
|
|
265
|
+
fi
|
|
266
|
+
done < <(collect_protected_snapshot_ids "${DIR_HASH:-}")
|
|
267
|
+
|
|
268
|
+
while IFS= read -r old_meta; do
|
|
269
|
+
old_id=$(jq -r '.snapshot_id // empty' "${old_meta}" 2>/dev/null || true)
|
|
270
|
+
|
|
271
|
+
if [[ -z "${old_id}" ]]; then
|
|
272
|
+
continue
|
|
273
|
+
fi
|
|
274
|
+
|
|
275
|
+
if [[ ${#protected_snapshot_ids[@]} -gt 0 ]] && snapshot_is_protected "${old_id}" "${protected_snapshot_ids[@]}"; then
|
|
276
|
+
continue
|
|
277
|
+
fi
|
|
278
|
+
|
|
279
|
+
removable_seen=$((removable_seen + 1))
|
|
280
|
+
if [[ ${removable_seen} -le 10 ]]; then
|
|
281
|
+
continue
|
|
282
|
+
fi
|
|
283
|
+
|
|
284
|
+
rm -f "${SNAPSHOT_DIR}/${old_id}"_*
|
|
285
|
+
done < <(ls -t "${SNAPSHOT_DIR}"/*_meta.json 2>/dev/null || true)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
restore_node_modules() {
|
|
289
|
+
if ! command -v npm >/dev/null 2>&1; then
|
|
290
|
+
ROLLBACK_WARNINGS+=("npm is not installed; node_modules was not reinstalled")
|
|
291
|
+
return
|
|
292
|
+
fi
|
|
293
|
+
|
|
294
|
+
if [[ -f "${PROJECT_DIR}/package-lock.json" ]]; then
|
|
295
|
+
if (cd "${PROJECT_DIR}" && npm ci >/dev/null 2>&1); then
|
|
296
|
+
return
|
|
297
|
+
fi
|
|
298
|
+
ROLLBACK_WARNINGS+=("npm ci failed during rollback; retrying with npm install")
|
|
299
|
+
fi
|
|
300
|
+
|
|
301
|
+
if (cd "${PROJECT_DIR}" && rm -rf node_modules && npm install >/dev/null 2>&1); then
|
|
302
|
+
return
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
ROLLBACK_WARNINGS+=("node_modules reinstall failed; review the project manually")
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
# Read tool input from stdin
|
|
309
|
+
INPUT=$(cat)
|
|
310
|
+
|
|
311
|
+
# Only process Bash tool results
|
|
312
|
+
TOOL_NAME=$(echo "${INPUT}" | jq -r '.tool_name // empty' 2>/dev/null)
|
|
313
|
+
if [[ "${TOOL_NAME}" != "Bash" ]]; then
|
|
314
|
+
exit 0
|
|
315
|
+
fi
|
|
316
|
+
|
|
317
|
+
STATE_LOCK_HELD=true
|
|
318
|
+
acquire_state_lock
|
|
319
|
+
trap '[ "${STATE_LOCK_HELD:-}" = "true" ] && release_state_lock; STATE_LOCK_HELD=false' EXIT
|
|
320
|
+
|
|
321
|
+
# Check if we have a pending snapshot to verify (V-004: atomic state file)
|
|
322
|
+
if [[ ! -f "${GUARD_DIR}/current_state" ]]; then
|
|
323
|
+
# Legacy fallback for in-flight upgrades
|
|
324
|
+
if [[ ! -f "${GUARD_DIR}/current_snapshot_id" ]]; then
|
|
325
|
+
exit 0
|
|
326
|
+
fi
|
|
327
|
+
SNAPSHOT_ID=$(cat "${GUARD_DIR}/current_snapshot_id")
|
|
328
|
+
PROJECT_DIR=$(cat "${GUARD_DIR}/current_project_dir" 2>/dev/null || pwd)
|
|
329
|
+
rm -f "${GUARD_DIR}/current_snapshot_id" "${GUARD_DIR}/current_project_dir"
|
|
330
|
+
else
|
|
331
|
+
CURRENT_STATE=$(cat "${GUARD_DIR}/current_state")
|
|
332
|
+
SNAPSHOT_ID=$(echo "${CURRENT_STATE}" | jq -r '.snapshot_id // empty')
|
|
333
|
+
PROJECT_DIR=$(echo "${CURRENT_STATE}" | jq -r '.project_dir // empty')
|
|
334
|
+
DIR_HASH=$(echo "${CURRENT_STATE}" | jq -r '.dir_hash // empty')
|
|
335
|
+
rm -f "${GUARD_DIR}/current_state"
|
|
336
|
+
fi
|
|
337
|
+
|
|
338
|
+
if [[ -z "${SNAPSHOT_ID}" ]]; then
|
|
339
|
+
exit 0
|
|
340
|
+
fi
|
|
341
|
+
if [[ -z "${PROJECT_DIR}" ]]; then
|
|
342
|
+
PROJECT_DIR=$(pwd)
|
|
343
|
+
fi
|
|
344
|
+
if [[ -z "${DIR_HASH:-}" ]]; then
|
|
345
|
+
DIR_HASH=$(compute_dir_hash "${PROJECT_DIR}")
|
|
346
|
+
fi
|
|
347
|
+
release_state_lock; STATE_LOCK_HELD=false
|
|
348
|
+
|
|
349
|
+
# Verify snapshot exists
|
|
350
|
+
META_FILE="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_meta.json"
|
|
351
|
+
if [[ ! -f "${META_FILE}" ]]; then
|
|
352
|
+
exit 0
|
|
353
|
+
fi
|
|
354
|
+
|
|
355
|
+
# --- Begin Reorg Verification ---
|
|
356
|
+
|
|
357
|
+
SUSPICIOUS=false
|
|
358
|
+
REASONS=()
|
|
359
|
+
ROLLBACK_WARNINGS=()
|
|
360
|
+
|
|
361
|
+
# Function: check for suspicious postinstall scripts in new/changed dependencies
|
|
362
|
+
check_postinstall_scripts() {
|
|
363
|
+
local pkg_json="${PROJECT_DIR}/package.json"
|
|
364
|
+
local changed_lock=false
|
|
365
|
+
local lock_file
|
|
366
|
+
|
|
367
|
+
if [[ ! -f "${pkg_json}" ]]; then
|
|
368
|
+
return
|
|
369
|
+
fi
|
|
370
|
+
|
|
371
|
+
for lock_file in "${SAFEDEPS_LOCK_FILES[@]}"; do
|
|
372
|
+
if files_differ "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${lock_file}" "${PROJECT_DIR}/${lock_file}"; then
|
|
373
|
+
changed_lock=true
|
|
374
|
+
break
|
|
375
|
+
fi
|
|
376
|
+
done
|
|
377
|
+
|
|
378
|
+
if [[ "${changed_lock}" != "true" ]] && ! files_differ "${SNAPSHOT_DIR}/${SNAPSHOT_ID}_package.json" "${pkg_json}"; then
|
|
379
|
+
return
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
# Check node_modules for new packages with install scripts
|
|
383
|
+
if [[ -d "${PROJECT_DIR}/node_modules" ]]; then
|
|
384
|
+
# Find packages with postinstall/preinstall scripts
|
|
385
|
+
local script_packages
|
|
386
|
+
local old_pkg_listing="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_packages.list"
|
|
387
|
+
if [[ -f "${old_pkg_listing}" ]]; then
|
|
388
|
+
script_packages=$(find "${PROJECT_DIR}/node_modules" -maxdepth 3 -name "package.json" 2>/dev/null | sort | comm -13 "${old_pkg_listing}" - | head -50)
|
|
389
|
+
else
|
|
390
|
+
script_packages=$(find "${PROJECT_DIR}/node_modules" -maxdepth 3 -name "package.json" 2>/dev/null | head -50)
|
|
391
|
+
fi
|
|
392
|
+
|
|
393
|
+
while IFS= read -r pkg; do
|
|
394
|
+
[[ -z "${pkg}" ]] && continue
|
|
395
|
+
# Check for suspicious install hooks
|
|
396
|
+
local has_preinstall
|
|
397
|
+
local has_postinstall
|
|
398
|
+
local has_install
|
|
399
|
+
local pkg_name
|
|
400
|
+
|
|
401
|
+
has_preinstall=$(jq -r '.scripts.preinstall // empty' "${pkg}" 2>/dev/null)
|
|
402
|
+
has_postinstall=$(jq -r '.scripts.postinstall // empty' "${pkg}" 2>/dev/null)
|
|
403
|
+
has_install=$(jq -r '.scripts.install // empty' "${pkg}" 2>/dev/null)
|
|
404
|
+
pkg_name=$(jq -r '.name // "unknown"' "${pkg}" 2>/dev/null)
|
|
405
|
+
|
|
406
|
+
for script_content in "${has_preinstall}" "${has_postinstall}" "${has_install}"; do
|
|
407
|
+
if [[ -z "${script_content}" ]]; then
|
|
408
|
+
continue
|
|
409
|
+
fi
|
|
410
|
+
|
|
411
|
+
# Check for network calls in install scripts
|
|
412
|
+
if echo "${script_content}" | grep -qEi '(curl|wget|fetch|http|https|net\.|socket|dns)'; then
|
|
413
|
+
SUSPICIOUS=true
|
|
414
|
+
REASONS+=("Package '${pkg_name}' has install script with network access: ${script_content}")
|
|
415
|
+
fi
|
|
416
|
+
|
|
417
|
+
# Check for eval/exec in install scripts
|
|
418
|
+
if echo "${script_content}" | grep -qEi '(eval|exec|spawn|child_process|Function\()'; then
|
|
419
|
+
SUSPICIOUS=true
|
|
420
|
+
REASONS+=("Package '${pkg_name}' has install script with code execution: ${script_content}")
|
|
421
|
+
fi
|
|
422
|
+
|
|
423
|
+
# Check for filesystem access outside project
|
|
424
|
+
if echo "${script_content}" | grep -qEi '(\/etc\/|\/home\/|~\/|\$HOME|\.ssh|\.env|\.aws|credentials)'; then
|
|
425
|
+
SUSPICIOUS=true
|
|
426
|
+
REASONS+=("Package '${pkg_name}' has install script accessing sensitive paths")
|
|
427
|
+
fi
|
|
428
|
+
|
|
429
|
+
# Check for encoded/obfuscated content
|
|
430
|
+
if echo "${script_content}" | grep -qEi '(base64|atob|Buffer\.from|\\x[0-9a-f]{2}|\\u[0-9a-f]{4})'; then
|
|
431
|
+
SUSPICIOUS=true
|
|
432
|
+
REASONS+=("Package '${pkg_name}' has install script with obfuscated content")
|
|
433
|
+
fi
|
|
434
|
+
done
|
|
435
|
+
done <<< "${script_packages}"
|
|
436
|
+
fi
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
# Function: check lock file diff for suspicious changes
|
|
440
|
+
check_lockfile_diff() {
|
|
441
|
+
local lock_file
|
|
442
|
+
|
|
443
|
+
for lock_file in "${SAFEDEPS_LOCK_FILES[@]}"; do
|
|
444
|
+
local current="${PROJECT_DIR}/${lock_file}"
|
|
445
|
+
local snapshot="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_${lock_file}"
|
|
446
|
+
|
|
447
|
+
if [[ ! -f "${current}" ]] || [[ ! -f "${snapshot}" ]]; then
|
|
448
|
+
continue
|
|
449
|
+
fi
|
|
450
|
+
|
|
451
|
+
# Compare content directly so mtime manipulation cannot bypass verification.
|
|
452
|
+
if ! files_differ "${snapshot}" "${current}"; then
|
|
453
|
+
continue
|
|
454
|
+
fi
|
|
455
|
+
|
|
456
|
+
# Lock file changed — analyze the diff
|
|
457
|
+
if [[ "${lock_file}" == "package-lock.json" ]]; then
|
|
458
|
+
local suspicious_urls
|
|
459
|
+
local insecure_urls
|
|
460
|
+
local new_deps
|
|
461
|
+
|
|
462
|
+
# Check for resolved URLs pointing to non-standard registries
|
|
463
|
+
suspicious_urls=$(diff "${snapshot}" "${current}" 2>/dev/null | grep '^>' | grep '"resolved"' | grep -viE 'registry\.npmjs\.org|registry\.yarnpkg\.com' | head -5 || true)
|
|
464
|
+
if [[ -n "${suspicious_urls}" ]]; then
|
|
465
|
+
SUSPICIOUS=true
|
|
466
|
+
REASONS+=("Lock file contains resolved URLs from non-standard registries")
|
|
467
|
+
fi
|
|
468
|
+
|
|
469
|
+
# Check for git:// or http:// (non-https) resolved URLs
|
|
470
|
+
insecure_urls=$(diff "${snapshot}" "${current}" 2>/dev/null | grep '^>' | grep '"resolved"' | grep -iE '(git://|http://)' | head -5 || true)
|
|
471
|
+
if [[ -n "${insecure_urls}" ]]; then
|
|
472
|
+
SUSPICIOUS=true
|
|
473
|
+
REASONS+=("Lock file contains insecure (non-HTTPS) resolved URLs")
|
|
474
|
+
fi
|
|
475
|
+
|
|
476
|
+
# Check for a very large number of new dependencies (potential dependency confusion)
|
|
477
|
+
new_deps=$(diff "${snapshot}" "${current}" 2>/dev/null | grep '^>' | grep -c '"resolved"' || true)
|
|
478
|
+
new_deps="${new_deps:-0}"
|
|
479
|
+
if [[ ${new_deps} -gt 50 ]]; then
|
|
480
|
+
SUSPICIOUS=true
|
|
481
|
+
REASONS+=("Unusually large number of new dependencies added: ${new_deps}")
|
|
482
|
+
fi
|
|
483
|
+
fi
|
|
484
|
+
done
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
# Function: check for suspicious binaries
|
|
488
|
+
check_binaries() {
|
|
489
|
+
if [[ -d "${PROJECT_DIR}/node_modules/.bin" ]]; then
|
|
490
|
+
# Check for newly added binaries that are actual compiled binaries (not scripts)
|
|
491
|
+
local new_bins
|
|
492
|
+
local old_bin_listing="${SNAPSHOT_DIR}/${SNAPSHOT_ID}_bins.list"
|
|
493
|
+
if [[ -f "${old_bin_listing}" ]]; then
|
|
494
|
+
new_bins=$(ls "${PROJECT_DIR}/node_modules/.bin/" 2>/dev/null | sort | comm -13 "${old_bin_listing}" - | head -20)
|
|
495
|
+
else
|
|
496
|
+
new_bins=$(ls "${PROJECT_DIR}/node_modules/.bin/" 2>/dev/null | head -20)
|
|
497
|
+
fi
|
|
498
|
+
|
|
499
|
+
for bin in ${new_bins}; do
|
|
500
|
+
# Check if it's a binary file (not a script) — use full path (V-010)
|
|
501
|
+
local bin_path="${PROJECT_DIR}/node_modules/.bin/${bin}"
|
|
502
|
+
if [[ -f "${bin_path}" ]] && file "${bin_path}" 2>/dev/null | grep -qiE '(executable|shared object|Mach-O|ELF)'; then
|
|
503
|
+
SUSPICIOUS=true
|
|
504
|
+
REASONS+=("Native binary '${bin}' found in node_modules/.bin")
|
|
505
|
+
fi
|
|
506
|
+
done
|
|
507
|
+
fi
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
# Run all checks
|
|
511
|
+
check_postinstall_scripts
|
|
512
|
+
check_lockfile_diff
|
|
513
|
+
check_binaries
|
|
514
|
+
|
|
515
|
+
# --- Reorg Decision ---
|
|
516
|
+
|
|
517
|
+
if [[ "${SUSPICIOUS}" == "true" ]]; then
|
|
518
|
+
# REORG: Rollback to last confirmed safe snapshot
|
|
519
|
+
ROLLBACK_SNAPSHOT_ID=$(read_confirmed_snapshot "${DIR_HASH}")
|
|
520
|
+
if [[ -z "${ROLLBACK_SNAPSHOT_ID}" ]] || [[ ! -f "${SNAPSHOT_DIR}/${ROLLBACK_SNAPSHOT_ID}_meta.json" ]]; then
|
|
521
|
+
ROLLBACK_SNAPSHOT_ID="${SNAPSHOT_ID}"
|
|
522
|
+
fi
|
|
523
|
+
|
|
524
|
+
ROLLED_BACK=()
|
|
525
|
+
|
|
526
|
+
while IFS= read -r monitored_file; do
|
|
527
|
+
[[ -z "${monitored_file}" ]] && continue
|
|
528
|
+
restore_monitored_file "${monitored_file}" "${ROLLBACK_SNAPSHOT_ID}"
|
|
529
|
+
done < <(monitored_files)
|
|
530
|
+
|
|
531
|
+
while IFS= read -r csproj_file; do
|
|
532
|
+
[[ -z "${csproj_file}" ]] && continue
|
|
533
|
+
restore_monitored_file "${csproj_file}" "${ROLLBACK_SNAPSHOT_ID}"
|
|
534
|
+
done < <(find "${PROJECT_DIR}" -maxdepth 1 -type f -name "*.csproj" -exec basename {} \; 2>/dev/null | sort)
|
|
535
|
+
|
|
536
|
+
while IFS= read -r snap_csproj; do
|
|
537
|
+
[[ -z "${snap_csproj}" ]] && continue
|
|
538
|
+
restore_monitored_file "${snap_csproj}" "${ROLLBACK_SNAPSHOT_ID}"
|
|
539
|
+
done < <(find "${SNAPSHOT_DIR}" -maxdepth 1 -type f -name "${ROLLBACK_SNAPSHOT_ID}_*.csproj" -exec basename {} \; 2>/dev/null | sed "s/^${ROLLBACK_SNAPSHOT_ID}_//" | sort)
|
|
540
|
+
|
|
541
|
+
while IFS= read -r missing_csproj; do
|
|
542
|
+
[[ -z "${missing_csproj}" ]] && continue
|
|
543
|
+
restore_monitored_file "${missing_csproj}" "${ROLLBACK_SNAPSHOT_ID}"
|
|
544
|
+
done < <(find "${SNAPSHOT_DIR}" -maxdepth 1 -type f -name "${ROLLBACK_SNAPSHOT_ID}_*.csproj.missing" -exec basename {} \; 2>/dev/null | sed "s/^${ROLLBACK_SNAPSHOT_ID}_//; s/\\.missing$//" | sort)
|
|
545
|
+
|
|
546
|
+
# Restore package.json if it was modified
|
|
547
|
+
rollback_package_json="${SNAPSHOT_DIR}/${ROLLBACK_SNAPSHOT_ID}_package.json"
|
|
548
|
+
current_package_json="${PROJECT_DIR}/package.json"
|
|
549
|
+
if [[ -f "${rollback_package_json}" ]] && files_differ "${rollback_package_json}" "${current_package_json}"; then
|
|
550
|
+
cp "${rollback_package_json}" "${current_package_json}"
|
|
551
|
+
ROLLED_BACK+=("package.json")
|
|
552
|
+
fi
|
|
553
|
+
|
|
554
|
+
restore_node_modules
|
|
555
|
+
cleanup_old_snapshots
|
|
556
|
+
|
|
557
|
+
REASON_STR=$(printf '%s; ' "${REASONS[@]}")
|
|
558
|
+
ROLLED_BACK_STR=$(printf '%s, ' "${ROLLED_BACK[@]}")
|
|
559
|
+
WARNING_STR=""
|
|
560
|
+
if [[ ${#ROLLBACK_WARNINGS[@]} -gt 0 ]]; then
|
|
561
|
+
WARNING_STR=$(printf '%s; ' "${ROLLBACK_WARNINGS[@]}")
|
|
562
|
+
fi
|
|
563
|
+
|
|
564
|
+
# Log the reorg event
|
|
565
|
+
cat >> "${GUARD_DIR}/reorg.log" << LOG_EOF
|
|
566
|
+
[$(date -u +"%Y-%m-%dT%H:%M:%SZ")] REORG executed
|
|
567
|
+
Snapshot: ${SNAPSHOT_ID}
|
|
568
|
+
Rollback snapshot: ${ROLLBACK_SNAPSHOT_ID}
|
|
569
|
+
Project: ${PROJECT_DIR}
|
|
570
|
+
Reasons: ${REASON_STR%%; }
|
|
571
|
+
Rolled back: ${ROLLED_BACK_STR%, }
|
|
572
|
+
Rollback warnings: ${WARNING_STR%%; }
|
|
573
|
+
LOG_EOF
|
|
574
|
+
|
|
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
|
|
578
|
+
exit 0
|
|
579
|
+
fi
|
|
580
|
+
|
|
581
|
+
confirm_snapshot "${SNAPSHOT_ID}" "${DIR_HASH}"
|
|
582
|
+
cleanup_old_snapshots
|
|
583
|
+
|
|
584
|
+
exit 0
|