@aldegad/safedeps 2.2.0 → 2.4.1
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 +5 -1
- package/README.ko.md +42 -0
- package/README.md +42 -0
- package/ROADMAP.md +51 -2
- package/SECURITY.md +45 -0
- package/SKILL.md +84 -147
- package/bin/safedeps +36 -2
- package/lib/gates/doctor.sh +212 -0
- package/lib/gates/hooks.sh +40 -2
- package/lib/gates/templates/gitleaks.private.toml.tmpl +45 -0
- package/lib/gates/templates/gitleaks.toml.tmpl +43 -0
- package/lib/gates/templates/pre-commit.tmpl +49 -0
- package/lib/providers/providers.sh +4 -1
- package/package.json +2 -1
- package/scripts/install/install-safedeps-hooks.mjs +3 -0
- package/scripts/safedeps-post-verify.sh +74 -15
- package/scripts/safedeps-pre-guard.sh +73 -9
- package/scripts/test/e2e.sh +51 -3
- package/scripts/test/smoke.sh +106 -6
|
@@ -36,8 +36,26 @@ SAFEDEPS_MANIFEST_FILES=(
|
|
|
36
36
|
umask 077
|
|
37
37
|
mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
|
|
38
38
|
|
|
39
|
+
# Observable record of any gate bypass / unavailability (AGENTS.md: no silent fallback —
|
|
40
|
+
# every bypass must be observable and logged).
|
|
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
|
+
|
|
39
45
|
if ! command -v jq >/dev/null 2>&1; then
|
|
40
|
-
|
|
46
|
+
# jq is required to parse the hook payload. Without it we cannot read the exact
|
|
47
|
+
# command, so do a best-effort fail-closed: read the raw payload and, if it
|
|
48
|
+
# looks like a dependency install, DENY (an install we cannot verify must not
|
|
49
|
+
# proceed). Non-install commands are allowed — jq absence must not block `ls`.
|
|
50
|
+
# Either branch is recorded in advisory.log; never a silent skip.
|
|
51
|
+
raw_input=$(cat)
|
|
52
|
+
log_advisory "pre-guard: jq missing — gate cannot parse the payload."
|
|
53
|
+
if printf '%s' "${raw_input}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[: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([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
|
|
54
|
+
log_advisory "pre-guard DENY: jq missing on a likely dependency-install command — fail-closed."
|
|
55
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"safedeps: jq is required to gate dependency installs and is not installed — install blocked fail-closed. Install jq, then retry."}}\n'
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
echo "safedeps: jq is not installed — install gate disabled (non-install commands still allowed); logged to advisory.log." >&2
|
|
41
59
|
exit 0
|
|
42
60
|
fi
|
|
43
61
|
|
|
@@ -48,8 +66,10 @@ acquire_state_lock() {
|
|
|
48
66
|
# Detect stale locks left by SIGKILL/OOM (V-005)
|
|
49
67
|
if [[ -d "${STATE_LOCK_DIR}" ]]; then
|
|
50
68
|
local lock_mtime=""
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
# GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
|
|
70
|
+
# means --file-system and would not yield an mtime.
|
|
71
|
+
if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
|
|
72
|
+
lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
|
|
53
73
|
local now
|
|
54
74
|
now=$(date +%s)
|
|
55
75
|
if [[ $(( now - lock_mtime )) -gt 60 ]]; then
|
|
@@ -61,8 +81,11 @@ acquire_state_lock() {
|
|
|
61
81
|
fi
|
|
62
82
|
|
|
63
83
|
attempts=$((attempts + 1))
|
|
64
|
-
if [[ ${attempts} -ge 100 ]]; then
|
|
65
|
-
|
|
84
|
+
if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
|
|
85
|
+
# acquire_state_lock is only reached for install candidates, so failing to
|
|
86
|
+
# serialize/snapshot means this install cannot be gated — fail CLOSED (deny).
|
|
87
|
+
log_advisory "pre-guard DENY: state lock unavailable for an install command — fail-closed."
|
|
88
|
+
jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"safedeps: could not acquire the state lock (another safedeps run may be active). Install blocked fail-closed — retry in a moment."}}'
|
|
66
89
|
exit 0
|
|
67
90
|
fi
|
|
68
91
|
sleep 0.1
|
|
@@ -100,6 +123,24 @@ compute_dir_hash() {
|
|
|
100
123
|
fi
|
|
101
124
|
}
|
|
102
125
|
|
|
126
|
+
# Per-install pending-state key (issue #5): dir hash + a hash of the command with
|
|
127
|
+
# the inert-install rewrite normalized out, so PreToolUse (original command) and
|
|
128
|
+
# PostToolUse (possibly `--ignore-scripts`-appended) of the SAME install resolve to
|
|
129
|
+
# the same key. This keeps concurrent installs in one project on separate pending
|
|
130
|
+
# files instead of clobbering a single global one.
|
|
131
|
+
compute_pending_key() {
|
|
132
|
+
local dir_hash="$1" command="$2" norm cmd_hash
|
|
133
|
+
norm=$(printf '%s' "${command}" | sed -E 's/[[:space:]]+--ignore-scripts([[:space:]]|$)/ /g; s/[[:space:]]+/ /g; s/^ //; s/ $//')
|
|
134
|
+
if command -v md5sum >/dev/null 2>&1; then
|
|
135
|
+
cmd_hash=$(printf '%s' "${norm}" | md5sum | cut -d' ' -f1)
|
|
136
|
+
elif command -v md5 >/dev/null 2>&1; then
|
|
137
|
+
cmd_hash=$(md5 -q -s "${norm}")
|
|
138
|
+
else
|
|
139
|
+
cmd_hash=$(printf '%s' "${norm}" | cksum | cut -d' ' -f1)
|
|
140
|
+
fi
|
|
141
|
+
printf '%s_%s' "${dir_hash}" "${cmd_hash}"
|
|
142
|
+
}
|
|
143
|
+
|
|
103
144
|
command_is_dependency_install() {
|
|
104
145
|
local command="$1"
|
|
105
146
|
local scan_command
|
|
@@ -438,7 +479,7 @@ fi
|
|
|
438
479
|
# Conservative: only block when at least one pkg@spec token is parseable. Bare
|
|
439
480
|
# `npm install` (lockfile install) falls through to the v1 reorg checks.
|
|
440
481
|
|
|
441
|
-
SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
|
|
482
|
+
SAFEDEPS_LEDGER_LIB="${SAFEDEPS_LEDGER_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh}"
|
|
442
483
|
SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
|
|
443
484
|
|
|
444
485
|
guard_detect_ecosystem() {
|
|
@@ -596,7 +637,16 @@ while IFS= read -r ledger_spec_line; do
|
|
|
596
637
|
LEDGER_SPECS+=("${ledger_spec_line}")
|
|
597
638
|
done < <(guard_extract_specs "${COMMAND}")
|
|
598
639
|
|
|
599
|
-
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0
|
|
640
|
+
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
|
|
641
|
+
if [[ ! -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
|
|
642
|
+
# The ledger library is the gate for direct install specs. If it is missing
|
|
643
|
+
# (broken install / moved repo) the gate cannot run — fail CLOSED, observably,
|
|
644
|
+
# instead of falling through to allow.
|
|
645
|
+
log_advisory "pre-guard DENY: ledger library missing (${SAFEDEPS_LEDGER_LIB}) — cannot enforce ${LEDGER_ECOSYSTEM} install, fail-closed."
|
|
646
|
+
jq -nc --arg eco "${LEDGER_ECOSYSTEM}" \
|
|
647
|
+
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("safedeps: the ledger library is missing, so the " + $eco + " install gate cannot run — install blocked fail-closed. Reinstall safedeps: node scripts/install/install-safedeps-hooks.mjs")}}'
|
|
648
|
+
exit 0
|
|
649
|
+
fi
|
|
600
650
|
# shellcheck source=../lib/ledger/ledger.sh
|
|
601
651
|
source "${SAFEDEPS_LEDGER_LIB}"
|
|
602
652
|
|
|
@@ -646,10 +696,24 @@ if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 && -f "${SAFEDEPS_LE
|
|
|
646
696
|
fi
|
|
647
697
|
fi
|
|
648
698
|
|
|
649
|
-
# Write
|
|
699
|
+
# Write per-install pending state for PostToolUse, keyed by (dir_hash, normalized
|
|
700
|
+
# command) so concurrent installs in the same project keep separate state instead
|
|
701
|
+
# of clobbering one global file (issue #5). The single-file write is still atomic
|
|
702
|
+
# (write_state_file) to prevent TOCTOU within one install.
|
|
703
|
+
PENDING_DIR="${GUARD_DIR}/pending"
|
|
704
|
+
mkdir -p "${PENDING_DIR}"
|
|
705
|
+
# GC pending entries whose PostToolUse never fired (crash/no-op). 24h is well past
|
|
706
|
+
# any real install, so this never deletes an in-flight one (a 60-min window could
|
|
707
|
+
# have reaped a slow native build that was still running).
|
|
708
|
+
find "${PENDING_DIR}" -name '*.json' -type f -mmin +1440 -delete 2>/dev/null || true
|
|
709
|
+
# Key = (dir, normalized command); the snapshot id suffix makes the filename unique
|
|
710
|
+
# per install, so even two identical concurrent commands keep separate state.
|
|
711
|
+
PENDING_KEY=$(compute_pending_key "${DIR_HASH}" "${COMMAND}")
|
|
650
712
|
CURRENT_STATE=$(jq -n --arg sid "${SNAPSHOT_ID}" --arg pdir "${PROJECT_DIR}" --arg dhash "${DIR_HASH}" \
|
|
651
713
|
'{snapshot_id: $sid, project_dir: $pdir, dir_hash: $dhash}')
|
|
652
|
-
|
|
714
|
+
# $$ (this pre hook's PID) guarantees a unique filename even for two installs in
|
|
715
|
+
# the same second (SNAPSHOT_ID has only 1s resolution).
|
|
716
|
+
write_state_file "${PENDING_DIR}/${PENDING_KEY}__${SNAPSHOT_ID}_$$.json" "${CURRENT_STATE}"
|
|
653
717
|
|
|
654
718
|
if ! jq -e 'has("turn_id")' <<< "${INPUT}" >/dev/null 2>&1 && \
|
|
655
719
|
command_is_injectable_npm_install "${COMMAND}" && \
|
package/scripts/test/e2e.sh
CHANGED
|
@@ -146,7 +146,7 @@ cat > "${effect_project}/package-lock.json" <<'EOF'
|
|
|
146
146
|
EOF
|
|
147
147
|
effect_clean_post=$(
|
|
148
148
|
scripts/safedeps-post-verify.sh <<EOF
|
|
149
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
|
|
149
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${effect_project}"}
|
|
150
150
|
EOF
|
|
151
151
|
)
|
|
152
152
|
[[ -z "${effect_clean_post}" ]] || fail "post hook passes approved full closure"
|
|
@@ -183,7 +183,7 @@ EOF
|
|
|
183
183
|
chmod +x "${stub_bin}/npm"
|
|
184
184
|
inert_post=$(
|
|
185
185
|
PATH="${stub_bin}:${PATH}" scripts/safedeps-post-verify.sh <<EOF
|
|
186
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0 --ignore-scripts"}}
|
|
186
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0 --ignore-scripts"},"cwd":"${inert_project}"}
|
|
187
187
|
EOF
|
|
188
188
|
)
|
|
189
189
|
[[ -z "${inert_post}" ]] || fail "post hook keeps verified inert rebuild success quiet"
|
|
@@ -220,7 +220,7 @@ cat > "${missing_project}/package-lock.json" <<'EOF'
|
|
|
220
220
|
EOF
|
|
221
221
|
missing_post=$(
|
|
222
222
|
scripts/safedeps-post-verify.sh <<EOF
|
|
223
|
-
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
|
|
223
|
+
{"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${missing_project}"}
|
|
224
224
|
EOF
|
|
225
225
|
)
|
|
226
226
|
grep -q '의심스러운 패키지 변경 감지' <<< "${missing_post}" || fail "post hook reorgs unapproved transitive package"
|
|
@@ -280,4 +280,52 @@ if jq -e '[.. | strings] | any(contains("npm-reorg-guard"))' "${installer_home}/
|
|
|
280
280
|
fi
|
|
281
281
|
pass "installer legacy hook cleanup"
|
|
282
282
|
|
|
283
|
+
# --- Secret-leak lane: pre-commit gate must DENY a secret, PASS clean/example -
|
|
284
|
+
# The real bypass harness for the secret lane. Needs a scanner (gitleaks or
|
|
285
|
+
# docker) and openssl for a synthetic high-entropy secret; skip explicitly
|
|
286
|
+
# (not silently) when either is missing.
|
|
287
|
+
secret_repo="${tmp_root}/secret-repo"
|
|
288
|
+
mkdir -p "${secret_repo}"
|
|
289
|
+
git -C "${secret_repo}" init -q
|
|
290
|
+
git -C "${secret_repo}" config user.email t@safedeps.test
|
|
291
|
+
git -C "${secret_repo}" config user.name safedeps-e2e
|
|
292
|
+
|
|
293
|
+
# doctor flags gaps on the bare repo, then --fix scaffolds + activates the lane.
|
|
294
|
+
if HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --root "${secret_repo}" >/dev/null 2>&1; then
|
|
295
|
+
fail "doctor flags gaps on an unconfigured repo"
|
|
296
|
+
fi
|
|
297
|
+
HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --fix --root "${secret_repo}" >/dev/null
|
|
298
|
+
[[ -f "${secret_repo}/.gitleaks.toml" ]] || fail "doctor --fix scaffolds .gitleaks.toml"
|
|
299
|
+
[[ -x "${secret_repo}/.githooks/pre-commit" ]] || fail "doctor --fix scaffolds executable pre-commit"
|
|
300
|
+
[[ "$(git -C "${secret_repo}" config --get core.hooksPath)" == ".githooks" ]] || fail "doctor --fix activates core.hooksPath"
|
|
301
|
+
pass "doctor --fix scaffolds + activates the secret lane"
|
|
302
|
+
|
|
303
|
+
# The scaffolded pre-commit resolves `safedeps` via PATH, then SAFEDEPS_BIN, then
|
|
304
|
+
# the skill install paths. In CI none of those exist, so point it at this repo's
|
|
305
|
+
# binary; the git commit subprocess inherits the env and the hook resolves it.
|
|
306
|
+
export SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps"
|
|
307
|
+
|
|
308
|
+
if command -v gitleaks >/dev/null 2>&1 && command -v openssl >/dev/null 2>&1; then
|
|
309
|
+
# Regression: a clean file commits cleanly.
|
|
310
|
+
echo "hello" > "${secret_repo}/readme.txt"
|
|
311
|
+
git -C "${secret_repo}" add readme.txt
|
|
312
|
+
git -C "${secret_repo}" commit -q -m "clean" || fail "pre-commit allows a clean commit"
|
|
313
|
+
|
|
314
|
+
# Threat: a literal .env with an assigned (synthetic) secret must be blocked.
|
|
315
|
+
printf 'API_KEY=%s\n' "$(openssl rand -hex 20)" > "${secret_repo}/.env"
|
|
316
|
+
git -C "${secret_repo}" add .env
|
|
317
|
+
if git -C "${secret_repo}" commit -q -m "leak" 2>/dev/null; then
|
|
318
|
+
fail "pre-commit blocks a committed .env secret"
|
|
319
|
+
fi
|
|
320
|
+
git -C "${secret_repo}" reset -q HEAD .env >/dev/null 2>&1 || true
|
|
321
|
+
|
|
322
|
+
# Regression: the .env.example placeholder is allowlisted and commits.
|
|
323
|
+
printf 'API_KEY=your_api_key_here\n' > "${secret_repo}/.env.example"
|
|
324
|
+
git -C "${secret_repo}" add .env.example
|
|
325
|
+
git -C "${secret_repo}" commit -q -m "example" || fail "pre-commit allows the .env.example placeholder"
|
|
326
|
+
pass "pre-commit gate denies a secret, passes clean and example commits"
|
|
327
|
+
else
|
|
328
|
+
printf 'ok - pre-commit gate behavior SKIPPED (needs gitleaks + openssl)\n'
|
|
329
|
+
fi
|
|
330
|
+
|
|
283
331
|
printf 'e2e passed\n'
|
package/scripts/test/smoke.sh
CHANGED
|
@@ -31,6 +31,7 @@ bash -n lib/gates/repo-profile.sh
|
|
|
31
31
|
bash -n lib/gates/scan.sh
|
|
32
32
|
bash -n lib/gates/audit.sh
|
|
33
33
|
bash -n lib/gates/hooks.sh
|
|
34
|
+
bash -n lib/gates/doctor.sh
|
|
34
35
|
pass "bash syntax"
|
|
35
36
|
|
|
36
37
|
node --check scripts/install/install-safedeps-hooks.mjs >/dev/null
|
|
@@ -58,6 +59,13 @@ provider_created=$(
|
|
|
58
59
|
[[ "${provider_created}" == "${provider_tmp%/}/safedeps-providers."* ]] || fail "provider tmp helper uses requested TMPDIR"
|
|
59
60
|
pass "provider temp dir"
|
|
60
61
|
|
|
62
|
+
# Portability guard: safedeps_file_mtime must return a bare integer on both BSD
|
|
63
|
+
# (macOS, `stat -f`) and GNU (Linux, `stat -c`). A wrong-order stat leaks
|
|
64
|
+
# filesystem info into the value and breaks the cache-freshness arithmetic.
|
|
65
|
+
mtime_val=$(bash -c 'source lib/providers/providers.sh; f=$(mktemp); safedeps_file_mtime "$f"; rm -f "$f"')
|
|
66
|
+
[[ "${mtime_val}" =~ ^[0-9]+$ ]] || fail "safedeps_file_mtime returns a bare integer (got: ${mtime_val})"
|
|
67
|
+
pass "file mtime is a portable integer"
|
|
68
|
+
|
|
61
69
|
project_dir="${tmp_root}/project"
|
|
62
70
|
mkdir -p "${project_dir}"
|
|
63
71
|
printf '{"dependencies":{}}\n' > "${project_dir}/package.json"
|
|
@@ -95,7 +103,7 @@ allow_output=$(
|
|
|
95
103
|
)
|
|
96
104
|
[[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${allow_output}")" == "allow" ]] || fail "hook emits Claude allow decision for approved install"
|
|
97
105
|
[[ "$(jq -r '.hookSpecificOutput.updatedInput.command' <<< "${allow_output}")" == "npm install left-pad@1.3.0 --ignore-scripts" ]] || fail "hook injects --ignore-scripts for Claude npm install"
|
|
98
|
-
allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/
|
|
106
|
+
allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/pending/"*.json)
|
|
99
107
|
jq -e '.ignore_scripts_injected == true' "${tmp_root}/safe-hook-allow/snapshots/${allow_sid}_meta.json" >/dev/null || fail "hook records injected meta flag"
|
|
100
108
|
pass "hook injects --ignore-scripts for Claude approved install"
|
|
101
109
|
|
|
@@ -105,7 +113,7 @@ codex_allow_output=$(
|
|
|
105
113
|
run_codex_hook_command "${tmp_root}/home-hook-codex" "${tmp_root}/safe-hook-codex" "npm install left-pad@1.3.0"
|
|
106
114
|
)
|
|
107
115
|
[[ -z "${codex_allow_output}" ]] || fail "hook keeps Codex approved install as plain allow"
|
|
108
|
-
codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/
|
|
116
|
+
codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/pending/"*.json)
|
|
109
117
|
jq -e '.ignore_scripts_injected == false' "${tmp_root}/safe-hook-codex/snapshots/${codex_sid}_meta.json" >/dev/null || fail "hook does not record injected meta flag for Codex"
|
|
110
118
|
pass "hook keeps Codex approved install as plain allow"
|
|
111
119
|
|
|
@@ -121,7 +129,7 @@ ignore_scripts_output=$(
|
|
|
121
129
|
run_hook_command "${tmp_root}/home-hook-ignore-scripts" "${tmp_root}/safe-hook-ignore-scripts" "npm install left-pad@1.3.0 --ignore-scripts"
|
|
122
130
|
)
|
|
123
131
|
[[ -z "${ignore_scripts_output}" ]] || fail "hook does not duplicate --ignore-scripts"
|
|
124
|
-
ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/
|
|
132
|
+
ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/pending/"*.json)
|
|
125
133
|
jq -e '.ignore_scripts_injected == false' "${tmp_root}/safe-hook-ignore-scripts/snapshots/${ignore_sid}_meta.json" >/dev/null || fail "hook does not record injected meta flag when flag already exists"
|
|
126
134
|
pass "hook does not duplicate --ignore-scripts"
|
|
127
135
|
|
|
@@ -171,6 +179,74 @@ for bypass_cmd in "${bypass_cases[@]}"; do
|
|
|
171
179
|
done
|
|
172
180
|
pass "hook denies install bypass forms"
|
|
173
181
|
|
|
182
|
+
# Fail-closed gate: when the gate cannot run it must NOT silently pass, and the
|
|
183
|
+
# outcome must be observable in the advisory log (AGENTS.md: no silent fallback).
|
|
184
|
+
fc_safe="${tmp_root}/safe-failclosed"
|
|
185
|
+
fc_home="${tmp_root}/home-failclosed"
|
|
186
|
+
mkdir -p "${fc_safe}"
|
|
187
|
+
# (a) lock unavailable on an install command → DENY (fail-closed), logged.
|
|
188
|
+
mkdir -p "${fc_safe}/state.lock"
|
|
189
|
+
fc_deny=$(
|
|
190
|
+
jq -nc --arg c "npm install evil@1.0.0" --arg cwd "${project_dir}" \
|
|
191
|
+
'{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
192
|
+
HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" SAFEDEPS_LOCK_MAX_ATTEMPTS=2 scripts/safedeps-pre-guard.sh
|
|
193
|
+
)
|
|
194
|
+
rmdir "${fc_safe}/state.lock" 2>/dev/null || true
|
|
195
|
+
[[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${fc_deny}")" == "deny" ]] || fail "pre-guard fails closed (deny) when the state lock is unavailable for an install"
|
|
196
|
+
grep -q 'pre-guard DENY' "${fc_safe}/advisory.log" || fail "pre-guard logs the fail-closed deny to advisory.log"
|
|
197
|
+
pass "pre-guard fails closed on lock contention (observable)"
|
|
198
|
+
|
|
199
|
+
# (b) jq missing → best-effort fail-closed: a likely install DENIES, a non-install
|
|
200
|
+
# is allowed, both recorded in advisory.log (never a silent skip).
|
|
201
|
+
fc_nojq=$(mktemp -d "${tmp_root}/nojq.XXXXXX")
|
|
202
|
+
for fc_tool in bash mkdir date printf cat grep; do
|
|
203
|
+
ln -sf "$(command -v "${fc_tool}")" "${fc_nojq}/${fc_tool}" 2>/dev/null || true
|
|
204
|
+
done
|
|
205
|
+
fc_nojq_deny=$(
|
|
206
|
+
jq -nc --arg c "npm install x@1" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
207
|
+
HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" PATH="${fc_nojq}" scripts/safedeps-pre-guard.sh 2>/dev/null
|
|
208
|
+
)
|
|
209
|
+
[[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${fc_nojq_deny}")" == "deny" ]] || fail "pre-guard denies a likely install when jq is missing (best-effort fail-closed)"
|
|
210
|
+
grep -q 'DENY: jq missing' "${fc_safe}/advisory.log" || fail "pre-guard logs the jq-missing install deny to advisory.log"
|
|
211
|
+
fc_nojq_allow=$(
|
|
212
|
+
jq -nc --arg c "ls -la" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
213
|
+
HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" PATH="${fc_nojq}" scripts/safedeps-pre-guard.sh 2>/dev/null
|
|
214
|
+
)
|
|
215
|
+
[[ "$(jq -r '.hookSpecificOutput.permissionDecision // "allow"' <<< "${fc_nojq_allow}" 2>/dev/null || echo allow)" != "deny" ]] || fail "pre-guard allows a non-install command when jq is missing"
|
|
216
|
+
pass "pre-guard fails closed on jq-missing installs, allows non-installs (observable)"
|
|
217
|
+
|
|
218
|
+
# (c) ledger library missing → DENY (fail-closed), logged — not a silent fall-through allow.
|
|
219
|
+
fc_noledger=$(
|
|
220
|
+
jq -nc --arg c "npm install x@1" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
221
|
+
HOME="${fc_home}" SAFEDEPS_HOME="${fc_safe}" SAFEDEPS_LEDGER_LIB="${tmp_root}/does-not-exist.sh" scripts/safedeps-pre-guard.sh 2>/dev/null
|
|
222
|
+
)
|
|
223
|
+
[[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${fc_noledger}")" == "deny" ]] || fail "pre-guard denies an install when the ledger library is missing (fail-closed)"
|
|
224
|
+
grep -q 'ledger library missing' "${fc_safe}/advisory.log" || fail "pre-guard logs the missing-ledger deny to advisory.log"
|
|
225
|
+
pass "pre-guard fails closed when the ledger library is missing (observable)"
|
|
226
|
+
|
|
227
|
+
# Concurrency (issue #5): two installs of the SAME command in one project must
|
|
228
|
+
# keep separate pending state — the per-install snapshot+PID suffix isolates them,
|
|
229
|
+
# not just the command hash — and a post hook must consume exactly one.
|
|
230
|
+
conc_safe="${tmp_root}/safe-concurrency"
|
|
231
|
+
mkdir -p "${conc_safe}"
|
|
232
|
+
SAFEDEPS_HOME="${conc_safe}" lib/ledger/ledger.sh approve npm conc-a 1.0.0 1.0.0 smoke >/dev/null
|
|
233
|
+
run_hook_command "${tmp_root}/home-conc" "${conc_safe}" "npm install conc-a@1.0.0" >/dev/null
|
|
234
|
+
run_hook_command "${tmp_root}/home-conc" "${conc_safe}" "npm install conc-a@1.0.0" >/dev/null
|
|
235
|
+
conc_pending=$(find "${conc_safe}/pending" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
236
|
+
[[ "${conc_pending}" == "2" ]] || fail "two identical concurrent installs keep two separate pending files (got ${conc_pending}, want 2)"
|
|
237
|
+
jq -nc --arg c "npm install conc-a@1.0.0" --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:$c},cwd:$cwd}' |
|
|
238
|
+
HOME="${tmp_root}/home-conc" SAFEDEPS_HOME="${conc_safe}" scripts/safedeps-post-verify.sh >/dev/null 2>&1 || true
|
|
239
|
+
conc_left=$(find "${conc_safe}/pending" -name '*.json' -type f | wc -l | tr -d ' ')
|
|
240
|
+
[[ "${conc_left}" == "1" ]] || fail "post hook consumes exactly one identical-command install's pending state (left ${conc_left}, want 1)"
|
|
241
|
+
pass "concurrent installs (even identical commands) keep isolated pending state (issue #5)"
|
|
242
|
+
|
|
243
|
+
# A dependency-install PostToolUse with no pending state (e.g. a payload missing
|
|
244
|
+
# cwd) is recorded UNVERIFIED, not dropped silently (issue #5 review finding 3).
|
|
245
|
+
jq -nc '{tool_name:"Bash",tool_input:{command:"npm install orphan@1.0.0"}}' |
|
|
246
|
+
HOME="${tmp_root}/home-conc" SAFEDEPS_HOME="${conc_safe}" scripts/safedeps-post-verify.sh >/dev/null 2>&1 || true
|
|
247
|
+
grep -q 'UNVERIFIED: no pending state for an install-looking command' "${conc_safe}/advisory.log" || fail "post hook records an install with no pending state as UNVERIFIED"
|
|
248
|
+
pass "post hook records an install-looking command with no pending state as UNVERIFIED"
|
|
249
|
+
|
|
174
250
|
tamper_safe="${tmp_root}/safe-tamper"
|
|
175
251
|
tamper_home="${tmp_root}/home-tamper"
|
|
176
252
|
SAFEDEPS_HOME="${tamper_safe}" lib/ledger/ledger.sh approve npm ledger-tamper 1.0.0 1.0.0 smoke >/dev/null
|
|
@@ -183,7 +259,7 @@ cat > "${project_dir}/node_modules/ledger-tamper/package.json" <<'EOF'
|
|
|
183
259
|
{"name":"ledger-tamper","version":"1.0.0","scripts":{"postinstall":"node -e \"require('fs').writeFileSync(process.env.HOME + '/.safedeps/approved-specs/evil.json', '{}')\""}}
|
|
184
260
|
EOF
|
|
185
261
|
tamper_post=$(
|
|
186
|
-
jq -nc '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"}}' |
|
|
262
|
+
jq -nc --arg cwd "${project_dir}" '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"},cwd:$cwd}' |
|
|
187
263
|
HOME="${tamper_home}" SAFEDEPS_HOME="${tamper_safe}" scripts/safedeps-post-verify.sh
|
|
188
264
|
)
|
|
189
265
|
grep -q '의심스러운 패키지 변경 감지' <<< "${tamper_post}" || fail "post hook reorgs safedeps ledger tamper script"
|
|
@@ -203,12 +279,36 @@ pass "re-check alert wrapper"
|
|
|
203
279
|
# Release-time lane (absorbed from security-release-gates): commands must be
|
|
204
280
|
# registered and resolve their gate scripts.
|
|
205
281
|
gates_help=$(HOME="${tmp_root}/home-gates" SAFEDEPS_HOME="${tmp_root}/safe-gates" ./bin/safedeps help)
|
|
206
|
-
for gate_cmd in "gates" "scan secrets" "audit" "hooks"; do
|
|
282
|
+
for gate_cmd in "gates" "scan secrets" "audit" "hooks" "doctor"; do
|
|
207
283
|
grep -q "${gate_cmd}" <<< "${gates_help}" || fail "release-time command listed in help: ${gate_cmd}"
|
|
208
284
|
done
|
|
209
|
-
for gate_script in scripts/release-gates.sh lib/gates/repo-profile.sh lib/gates/scan.sh lib/gates/audit.sh lib/gates/hooks.sh; do
|
|
285
|
+
for gate_script in scripts/release-gates.sh lib/gates/repo-profile.sh lib/gates/scan.sh lib/gates/audit.sh lib/gates/hooks.sh lib/gates/doctor.sh; do
|
|
210
286
|
[[ -f "${gate_script}" ]] || fail "release-time gate script present: ${gate_script}"
|
|
211
287
|
done
|
|
288
|
+
for tmpl in gitleaks.toml.tmpl gitleaks.private.toml.tmpl pre-commit.tmpl; do
|
|
289
|
+
[[ -f "lib/gates/templates/${tmpl}" ]] || fail "secret-lane template present: ${tmpl}"
|
|
290
|
+
done
|
|
212
291
|
pass "release-time gate commands registered"
|
|
213
292
|
|
|
293
|
+
# Secret-leak lane: doctor diagnoses, hooks init scaffolds, hooks install
|
|
294
|
+
# activates. No scanner (gitleaks/docker) needed for these structural checks.
|
|
295
|
+
doctor_repo=$(mktemp -d "${tmp_root}/secret-repo.XXXXXX")
|
|
296
|
+
git -C "${doctor_repo}" init -q
|
|
297
|
+
# doctor exits 1 when gaps exist; capture the JSON without tripping set -e.
|
|
298
|
+
doctor_json=$(HOME="${tmp_root}/home-doctor" ./bin/safedeps --json doctor --root "${doctor_repo}" || true)
|
|
299
|
+
[[ "$(jq -r '.command' <<< "${doctor_json}")" == "doctor" ]] || fail "doctor --json command field"
|
|
300
|
+
[[ "$(jq -r '.ok' <<< "${doctor_json}")" == "false" ]] || fail "doctor reports gaps on a bare repo"
|
|
301
|
+
secret_gaps=$(jq -r '[.checks[] | select(.lane == "secret" and .status == "gap")] | length' <<< "${doctor_json}")
|
|
302
|
+
[[ "${secret_gaps}" -ge 3 ]] || fail "doctor lists at least 3 secret-lane gaps (got ${secret_gaps})"
|
|
303
|
+
HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks init --root "${doctor_repo}" >/dev/null
|
|
304
|
+
[[ -f "${doctor_repo}/.gitleaks.toml" ]] || fail "hooks init scaffolds .gitleaks.toml"
|
|
305
|
+
[[ -x "${doctor_repo}/.githooks/pre-commit" ]] || fail "hooks init scaffolds an executable pre-commit"
|
|
306
|
+
grep -q 'scan secrets --staged' "${doctor_repo}/.githooks/pre-commit" || fail "pre-commit delegates to safedeps scan"
|
|
307
|
+
printf '\n# repo-owned edit marker\n' >> "${doctor_repo}/.gitleaks.toml"
|
|
308
|
+
HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks init --root "${doctor_repo}" >/dev/null
|
|
309
|
+
grep -q 'repo-owned edit marker' "${doctor_repo}/.gitleaks.toml" || fail "hooks init is non-destructive (keeps repo edits)"
|
|
310
|
+
HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks install --root "${doctor_repo}" >/dev/null
|
|
311
|
+
[[ "$(git -C "${doctor_repo}" config --get core.hooksPath)" == ".githooks" ]] || fail "hooks install activates core.hooksPath"
|
|
312
|
+
pass "doctor + hooks init/install wire the secret lane (non-destructive)"
|
|
313
|
+
|
|
214
314
|
printf 'smoke passed\n'
|