@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.
@@ -38,19 +38,68 @@ port=$(cat "${port_file}")
38
38
 
39
39
  export SAFEDEPS_HOME="${tmp_root}/safe"
40
40
  export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
41
+ export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
41
42
  export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
42
43
  export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
43
44
  export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
44
45
 
46
+ closure_fixture="${tmp_root}/closure-fixture.json"
47
+ cat > "${closure_fixture}" <<'EOF'
48
+ {
49
+ "fixture-clean@1.0.0": [
50
+ {"package":"fixture-clean","version":"1.0.0","direct":true}
51
+ ],
52
+ "fixture-vuln@1.0.0": [
53
+ {"package":"fixture-vuln","version":"1.0.0","direct":true}
54
+ ],
55
+ "fixture-vuln@1.0.1": [
56
+ {"package":"fixture-vuln","version":"1.0.1","direct":true}
57
+ ],
58
+ "fixture-multi-vuln@1.0.0": [
59
+ {"package":"fixture-multi-vuln","version":"1.0.0","direct":true}
60
+ ],
61
+ "fixture-multi-vuln@1.0.1": [
62
+ {"package":"fixture-multi-vuln","version":"1.0.1","direct":true}
63
+ ],
64
+ "fixture-multi-vuln@1.0.5": [
65
+ {"package":"fixture-multi-vuln","version":"1.0.5","direct":true}
66
+ ],
67
+ "fixture-unpatched@1.0.0": [
68
+ {"package":"fixture-unpatched","version":"1.0.0","direct":true}
69
+ ],
70
+ "fixture-kev@1.0.0": [
71
+ {"package":"fixture-kev","version":"1.0.0","direct":true}
72
+ ],
73
+ "fixture-parent@1.0.0": [
74
+ {"package":"fixture-parent","version":"1.0.0","direct":true},
75
+ {"package":"fixture-child","version":"1.0.0","direct":false}
76
+ ]
77
+ }
78
+ EOF
79
+ export SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON="${closure_fixture}"
80
+
45
81
  clean_json=$(./bin/safedeps --json check npm fixture-clean@1.0.0)
46
82
  [[ "$(jq -r '.result' <<< "${clean_json}")" == "clean" ]] || fail "clean fixture approved"
47
83
  pass "clean advisory approval"
48
84
 
85
+ closure_json=$(./bin/safedeps --json check npm fixture-parent@1.0.0)
86
+ [[ "$(jq -r '.result' <<< "${closure_json}")" == "clean" ]] || fail "closure fixture approved"
87
+ [[ "$(jq -r '.transitive_count' <<< "${closure_json}")" == "1" ]] || fail "closure fixture records transitive count"
88
+ parent_hash=$(jq -r '.spec_hash' <<< "${closure_json}")
89
+ parent_file="${SAFEDEPS_HOME}/approved-specs/${parent_hash/:/-}.json"
90
+ [[ "$(jq -r '.transitive_specs[0].package' "${parent_file}")" == "fixture-child" ]] || fail "ledger transitive_specs records fixture child"
91
+ pass "closure approval records transitive_specs"
92
+
49
93
  patched_json=$(./bin/safedeps --json check npm fixture-vuln@1.0.0)
50
94
  [[ "$(jq -r '.result' <<< "${patched_json}")" == "patched_available" ]] || fail "patched fixture narrows"
51
95
  [[ "$(jq -r '.suggested_spec' <<< "${patched_json}")" == "1.0.1" ]] || fail "patched fixture suggests fixed version"
52
96
  pass "patched advisory narrowing"
53
97
 
98
+ multi_patched_json=$(./bin/safedeps --json check npm fixture-multi-vuln@1.0.0)
99
+ [[ "$(jq -r '.result' <<< "${multi_patched_json}")" == "patched_available" ]] || fail "multi patched fixture narrows"
100
+ [[ "$(jq -r '.suggested_spec' <<< "${multi_patched_json}")" == "1.0.5" ]] || fail "multi patched fixture tries later clean fixed version"
101
+ pass "patched advisory tries all fixed candidates"
102
+
54
103
  set +e
55
104
  unpatched_json=$(./bin/safedeps --json check npm fixture-unpatched@1.0.0)
56
105
  unpatched_status=$?
@@ -68,18 +117,136 @@ mkdir -p "${project_dir}"
68
117
  printf '{"dependencies":{}}\n' > "${project_dir}/package.json"
69
118
  hook_allow=$(
70
119
  scripts/safedeps-pre-guard.sh <<EOF
71
- {"tool_name":"Bash","tool_input":{"command":"npm install fixture-vuln@1.0.1"},"cwd":"${project_dir}"}
120
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-vuln@1.0.1"},"cwd":"${project_dir}","turn_id":"turn-e2e","model":"codex-test"}
72
121
  EOF
73
122
  )
74
123
  [[ -z "${hook_allow}" ]] || fail "hook allows narrowed approved spec"
75
124
  pass "hook allows approved narrowed spec"
76
125
 
126
+ effect_project="${tmp_root}/effect-project"
127
+ mkdir -p "${effect_project}"
128
+ printf '{"dependencies":{}}\n' > "${effect_project}/package.json"
129
+
130
+ effect_clean_pre=$(
131
+ scripts/safedeps-pre-guard.sh <<EOF
132
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${effect_project}","turn_id":"turn-e2e","model":"codex-test"}
133
+ EOF
134
+ )
135
+ [[ -z "${effect_clean_pre}" ]] || fail "effect clean pre hook allows closure-approved direct spec"
136
+ cat > "${effect_project}/package-lock.json" <<'EOF'
137
+ {
138
+ "name": "effect-project",
139
+ "lockfileVersion": 3,
140
+ "packages": {
141
+ "": {"dependencies": {"fixture-parent": "1.0.0"}},
142
+ "node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
143
+ "node_modules/fixture-child": {"version": "1.0.0"}
144
+ }
145
+ }
146
+ EOF
147
+ effect_clean_post=$(
148
+ scripts/safedeps-post-verify.sh <<EOF
149
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
150
+ EOF
151
+ )
152
+ [[ -z "${effect_clean_post}" ]] || fail "post hook passes approved full closure"
153
+ pass "post hook passes approved full closure"
154
+
155
+ inert_project="${tmp_root}/inert-project"
156
+ mkdir -p "${inert_project}"
157
+ printf '{"dependencies":{}}\n' > "${inert_project}/package.json"
158
+ inert_pre=$(
159
+ scripts/safedeps-pre-guard.sh <<EOF
160
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${inert_project}"}
161
+ EOF
162
+ )
163
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${inert_pre}")" == "allow" ]] || fail "inert pre hook emits Claude allow"
164
+ [[ "$(jq -r '.hookSpecificOutput.updatedInput.command' <<< "${inert_pre}")" == "npm install fixture-parent@1.0.0 --ignore-scripts" ]] || fail "inert pre hook injects ignore-scripts"
165
+ cat > "${inert_project}/package-lock.json" <<'EOF'
166
+ {
167
+ "name": "inert-project",
168
+ "lockfileVersion": 3,
169
+ "packages": {
170
+ "": {"dependencies": {"fixture-parent": "1.0.0"}},
171
+ "node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
172
+ "node_modules/fixture-child": {"version": "1.0.0"}
173
+ }
174
+ }
175
+ EOF
176
+ stub_bin="${tmp_root}/stub-bin"
177
+ mkdir -p "${stub_bin}"
178
+ cat > "${stub_bin}/npm" <<EOF
179
+ #!/usr/bin/env bash
180
+ printf '%s\n' "\$*" >> "${tmp_root}/npm-calls.log"
181
+ exit 0
182
+ EOF
183
+ chmod +x "${stub_bin}/npm"
184
+ inert_post=$(
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"}}
187
+ EOF
188
+ )
189
+ [[ -z "${inert_post}" ]] || fail "post hook keeps verified inert rebuild success quiet"
190
+ grep -qx 'rebuild' "${tmp_root}/npm-calls.log" || fail "post hook runs npm rebuild after verified injected install"
191
+ pass "post hook rebuilds after verified inert install"
192
+
193
+ export SAFEDEPS_HOME="${tmp_root}/safe-missing-transitive"
194
+ export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
195
+ export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
196
+ export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
197
+ export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
198
+ export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
199
+ export SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON="${closure_fixture}"
200
+ missing_project="${tmp_root}/missing-project"
201
+ mkdir -p "${missing_project}"
202
+ printf '{"dependencies":{}}\n' > "${missing_project}/package.json"
203
+ SAFEDEPS_HOME="${SAFEDEPS_HOME}" lib/ledger/ledger.sh approve npm fixture-parent 1.0.0 1.0.0 direct-only >/dev/null
204
+ missing_pre=$(
205
+ scripts/safedeps-pre-guard.sh <<EOF
206
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"},"cwd":"${missing_project}","turn_id":"turn-e2e","model":"codex-test"}
207
+ EOF
208
+ )
209
+ [[ -z "${missing_pre}" ]] || fail "missing-transitive pre hook allows direct-only approved spec"
210
+ cat > "${missing_project}/package-lock.json" <<'EOF'
211
+ {
212
+ "name": "missing-project",
213
+ "lockfileVersion": 3,
214
+ "packages": {
215
+ "": {"dependencies": {"fixture-parent": "1.0.0"}},
216
+ "node_modules/fixture-parent": {"version": "1.0.0", "dependencies": {"fixture-child": "1.0.0"}},
217
+ "node_modules/fixture-child": {"version": "1.0.0"}
218
+ }
219
+ }
220
+ EOF
221
+ missing_post=$(
222
+ scripts/safedeps-post-verify.sh <<EOF
223
+ {"tool_name":"Bash","tool_input":{"command":"npm install fixture-parent@1.0.0"}}
224
+ EOF
225
+ )
226
+ grep -q '의심스러운 패키지 변경 감지' <<< "${missing_post}" || fail "post hook reorgs unapproved transitive package"
227
+ grep -q 'fixture-child@1.0.0' <<< "${missing_post}" || fail "post hook names unapproved transitive package"
228
+ pass "post hook reorgs unapproved transitive package"
229
+
230
+ export SAFEDEPS_HOME="${tmp_root}/safe"
231
+ export SAFEDEPS_OSV_API_URL="http://127.0.0.1:${port}/osv/v1/query"
232
+ export SAFEDEPS_OSV_BATCH_API_URL="http://127.0.0.1:${port}/osv/v1/querybatch"
233
+ export SAFEDEPS_KEV_CATALOG_URL="http://127.0.0.1:${port}/kev.json"
234
+ export SAFEDEPS_GHSA_API_URL="http://127.0.0.1:${port}/advisories"
235
+ export SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS=0
236
+ export SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON="${closure_fixture}"
237
+
77
238
  printf '%s\n' '{"vulnerable":["fixture-clean@1.0.0"]}' > "${state_file}"
78
239
  recheck_json=$(./bin/safedeps --json re-check)
79
240
  [[ "$(jq -r '.revoked | length' <<< "${recheck_json}")" == "1" ]] || fail "re-check revokes newly vulnerable spec"
80
241
  [[ "$(jq -r '.revoked[0].package' <<< "${recheck_json}")" == "fixture-clean" ]] || fail "re-check revoked expected package"
81
242
  pass "re-check revocation"
82
243
 
244
+ SAFEDEPS_HOME="${SAFEDEPS_HOME}" lib/ledger/ledger.sh approve npm fixture-forged 1.0.0 1.0.0 forged-test >/dev/null
245
+ forgery_json=$(./bin/safedeps --json re-check)
246
+ [[ "$(jq -r '.suspected_forgery | length' <<< "${forgery_json}")" == "1" ]] || fail "re-check flags direct ledger write without approval provenance"
247
+ [[ "$(jq -r '.suspected_forgery[0].package' <<< "${forgery_json}")" == "fixture-forged" ]] || fail "re-check flags expected forged package"
248
+ pass "re-check flags ledger approval provenance mismatch"
249
+
83
250
  legacy_home="${tmp_root}/legacy"
84
251
  target_home="${tmp_root}/migrated"
85
252
  mkdir -p "${legacy_home}/approved-specs"
@@ -93,15 +260,72 @@ pass "legacy state migration"
93
260
  installer_home="${tmp_root}/installer-home"
94
261
  mkdir -p "${installer_home}/.claude" "${installer_home}/.codex"
95
262
  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"}]}]}}
263
+ {"hooks":{"PreToolUse":[{"matcher":"Other","hooks":[{"type":"command","command":"~/.claude/skills/safedeps/scripts/safedeps-pre-guard.sh"}]},{"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
264
  EOF
98
265
  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)
266
+ jq -e --arg pre "~/.claude/skills/safedeps/scripts/safedeps-pre-guard.sh" '
267
+ [.hooks.PreToolUse[]? | select(.matcher == "Bash") | .hooks[]?.command] | index($pre)
101
268
  ' "${installer_home}/.claude/settings.json" >/dev/null || fail "installer writes new pre hook"
269
+ jq -e --arg post "~/.claude/skills/safedeps/scripts/safedeps-post-verify.sh" '
270
+ [.hooks.PostToolUse[]?.hooks[]?.command] | index($post)
271
+ ' "${installer_home}/.claude/settings.json" >/dev/null || fail "installer writes new post hook"
272
+ jq -e --arg pre "~/.codex/skills/safedeps/scripts/safedeps-pre-guard.sh" '
273
+ [.hooks.PreToolUse[]?.hooks[]?.command] | index($pre)
274
+ ' "${installer_home}/.codex/hooks.json" >/dev/null || fail "installer writes codex pre hook"
275
+ jq -e --arg post "~/.codex/skills/safedeps/scripts/safedeps-post-verify.sh" '
276
+ [.hooks.PostToolUse[]?.hooks[]?.command] | index($post)
277
+ ' "${installer_home}/.codex/hooks.json" >/dev/null || fail "installer writes codex post hook"
102
278
  if jq -e '[.. | strings] | any(contains("npm-reorg-guard"))' "${installer_home}/.claude/settings.json" >/dev/null; then
103
279
  fail "installer removes legacy hook"
104
280
  fi
105
281
  pass "installer legacy hook cleanup"
106
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
+
107
331
  printf 'e2e passed\n'
@@ -55,6 +55,17 @@ function osvResponse(packageName, version) {
55
55
  if (packageName === 'fixture-vuln' && version === '1.0.0') {
56
56
  return { vulns: [osvVuln('CVE-2026-1001', '1.0.1')] };
57
57
  }
58
+ if (packageName === 'fixture-multi-vuln' && version === '1.0.0') {
59
+ return {
60
+ vulns: [
61
+ osvVuln('CVE-2026-1003', '1.0.1'),
62
+ osvVuln('CVE-2026-1004', '1.0.5')
63
+ ]
64
+ };
65
+ }
66
+ if (packageName === 'fixture-multi-vuln' && version === '1.0.1') {
67
+ return { vulns: [osvVuln('CVE-2026-1004', '1.0.5')] };
68
+ }
58
69
  if (packageName === 'fixture-unpatched') {
59
70
  return { vulns: [osvVuln('CVE-2026-1002', null)] };
60
71
  }
@@ -74,6 +85,16 @@ const server = http.createServer(async (req, res) => {
74
85
  return;
75
86
  }
76
87
 
88
+ if (req.method === 'POST' && req.url === '/osv/v1/querybatch') {
89
+ const body = await readJson(req);
90
+ const queries = Array.isArray(body.queries) ? body.queries : [];
91
+ res.setHeader('content-type', 'application/json');
92
+ res.end(JSON.stringify({
93
+ results: queries.map((query) => osvResponse(query.package?.name || '', query.version || ''))
94
+ }));
95
+ return;
96
+ }
97
+
77
98
  if (req.method === 'GET' && req.url === '/kev.json') {
78
99
  res.setHeader('content-type', 'application/json');
79
100
  res.end(JSON.stringify({
@@ -22,9 +22,16 @@ trap cleanup EXIT
22
22
  bash -n bin/safedeps
23
23
  bash -n lib/providers/providers.sh
24
24
  bash -n lib/ledger/ledger.sh
25
+ bash -n lib/npm/closure.sh
25
26
  bash -n scripts/safedeps-pre-guard.sh
26
27
  bash -n scripts/safedeps-post-verify.sh
27
28
  bash -n scripts/safedeps-recheck-alert.sh
29
+ bash -n scripts/release-gates.sh
30
+ bash -n lib/gates/repo-profile.sh
31
+ bash -n lib/gates/scan.sh
32
+ bash -n lib/gates/audit.sh
33
+ bash -n lib/gates/hooks.sh
34
+ bash -n lib/gates/doctor.sh
28
35
  pass "bash syntax"
29
36
 
30
37
  node --check scripts/install/install-safedeps-hooks.mjs >/dev/null
@@ -35,7 +42,8 @@ node scripts/install/install-safedeps-recheck-agent.mjs --help >/dev/null
35
42
  pass "node syntax"
36
43
 
37
44
  version_json=$(HOME="${tmp_root}/home-version" SAFEDEPS_HOME="${tmp_root}/safe-version" ./bin/safedeps --json version)
38
- [[ "$(jq -r '.version' <<< "${version_json}")" == "2.1.0" ]] || fail "version json is 2.1.0"
45
+ pkg_version=$(jq -r '.version' package.json)
46
+ [[ "$(jq -r '.version' <<< "${version_json}")" == "${pkg_version}" ]] || fail "cli version matches package.json (${pkg_version})"
39
47
  pass "cli version"
40
48
 
41
49
  ledger_json=$(HOME="${tmp_root}/home-ledger" SAFEDEPS_HOME="${tmp_root}/safe-ledger" ./bin/safedeps --json ledger)
@@ -51,15 +59,39 @@ provider_created=$(
51
59
  [[ "${provider_created}" == "${provider_tmp%/}/safedeps-providers."* ]] || fail "provider tmp helper uses requested TMPDIR"
52
60
  pass "provider temp dir"
53
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
+
54
69
  project_dir="${tmp_root}/project"
55
70
  mkdir -p "${project_dir}"
56
71
  printf '{"dependencies":{}}\n' > "${project_dir}/package.json"
57
72
 
73
+ run_hook_command() {
74
+ local home_dir="$1"
75
+ local safe_dir="$2"
76
+ local command="$3"
77
+
78
+ jq -nc --arg command "${command}" --arg cwd "${project_dir}" \
79
+ '{tool_name:"Bash",tool_input:{command:$command},cwd:$cwd}' |
80
+ HOME="${home_dir}" SAFEDEPS_HOME="${safe_dir}" scripts/safedeps-pre-guard.sh
81
+ }
82
+
83
+ run_codex_hook_command() {
84
+ local home_dir="$1"
85
+ local safe_dir="$2"
86
+ local command="$3"
87
+
88
+ jq -nc --arg command "${command}" --arg cwd "${project_dir}" \
89
+ '{tool_name:"Bash",tool_input:{command:$command},cwd:$cwd,turn_id:"turn-smoke",model:"codex-test"}' |
90
+ HOME="${home_dir}" SAFEDEPS_HOME="${safe_dir}" scripts/safedeps-pre-guard.sh
91
+ }
92
+
58
93
  deny_json=$(
59
- HOME="${tmp_root}/home-hook" SAFEDEPS_HOME="${tmp_root}/safe-hook" \
60
- scripts/safedeps-pre-guard.sh <<EOF
61
- {"tool_name":"Bash","tool_input":{"command":"npm install left-pad@1.3.0"},"cwd":"${project_dir}"}
62
- EOF
94
+ run_hook_command "${tmp_root}/home-hook" "${tmp_root}/safe-hook" "npm install left-pad@1.3.0"
63
95
  )
64
96
  [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${deny_json}")" == "deny" ]] || fail "hook denies unapproved install"
65
97
  pass "hook denies unapproved install"
@@ -67,13 +99,148 @@ pass "hook denies unapproved install"
67
99
  mkdir -p "${tmp_root}/safe-hook-allow"
68
100
  SAFEDEPS_HOME="${tmp_root}/safe-hook-allow" lib/ledger/ledger.sh approve npm left-pad 1.3.0 1.3.0 smoke >/dev/null
69
101
  allow_output=$(
70
- HOME="${tmp_root}/home-hook-allow" SAFEDEPS_HOME="${tmp_root}/safe-hook-allow" \
71
- scripts/safedeps-pre-guard.sh <<EOF
72
- {"tool_name":"Bash","tool_input":{"command":"npm install left-pad@1.3.0"},"cwd":"${project_dir}"}
102
+ run_hook_command "${tmp_root}/home-hook-allow" "${tmp_root}/safe-hook-allow" "npm install left-pad@1.3.0"
103
+ )
104
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${allow_output}")" == "allow" ]] || fail "hook emits Claude allow decision for approved install"
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"
106
+ allow_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-allow/current_state")
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"
108
+ pass "hook injects --ignore-scripts for Claude approved install"
109
+
110
+ mkdir -p "${tmp_root}/safe-hook-codex"
111
+ SAFEDEPS_HOME="${tmp_root}/safe-hook-codex" lib/ledger/ledger.sh approve npm left-pad 1.3.0 1.3.0 smoke >/dev/null
112
+ codex_allow_output=$(
113
+ run_codex_hook_command "${tmp_root}/home-hook-codex" "${tmp_root}/safe-hook-codex" "npm install left-pad@1.3.0"
114
+ )
115
+ [[ -z "${codex_allow_output}" ]] || fail "hook keeps Codex approved install as plain allow"
116
+ codex_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-codex/current_state")
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"
118
+ pass "hook keeps Codex approved install as plain allow"
119
+
120
+ for inert_skip_cmd in "npm view left-pad" "npm run build" "npm --version"; do
121
+ inert_skip_output=$(run_hook_command "${tmp_root}/home-inert-skip" "${tmp_root}/safe-inert-skip" "${inert_skip_cmd}")
122
+ [[ -z "${inert_skip_output}" ]] || fail "hook does not inject non-install command: ${inert_skip_cmd}"
123
+ done
124
+ pass "hook does not inject npm non-install commands"
125
+
126
+ mkdir -p "${tmp_root}/safe-hook-ignore-scripts"
127
+ SAFEDEPS_HOME="${tmp_root}/safe-hook-ignore-scripts" lib/ledger/ledger.sh approve npm left-pad 1.3.0 1.3.0 smoke >/dev/null
128
+ ignore_scripts_output=$(
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"
130
+ )
131
+ [[ -z "${ignore_scripts_output}" ]] || fail "hook does not duplicate --ignore-scripts"
132
+ ignore_sid=$(jq -r '.snapshot_id' "${tmp_root}/safe-hook-ignore-scripts/current_state")
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"
134
+ pass "hook does not duplicate --ignore-scripts"
135
+
136
+ # Regression: `npx <tool> <args>` runs an already-installed binary. Arguments to
137
+ # the tool (e.g. an email) must NOT be misread as a pkg@spec install and denied.
138
+ npx_runner_output=$(
139
+ run_hook_command "${tmp_root}/home-npx-run" "${tmp_root}/safe-npx-run" "npx wrangler secret put ORIGIN_SHARED_SECRET --name pqc-auth-gateway dev1@block-s.io"
140
+ )
141
+ [[ -z "${npx_runner_output}" ]] || fail "hook allows npx tool run with @-bearing args"
142
+ pass "hook allows npx tool run with @-bearing args"
143
+
144
+ # Regression: a genuine install chained with an npx tool run must STILL be gated
145
+ # on the real package — and must not be polluted by the npx arg email.
146
+ mixed_output=$(
147
+ run_hook_command "${tmp_root}/home-mixed" "${tmp_root}/safe-mixed" "npm install evil-pkg@9.9.9 && npx wrangler secret put X dev1@block-s.io"
148
+ )
149
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${mixed_output}")" == "deny" ]] || fail "hook gates real install chained with npx run"
150
+ reason=$(jq -r '.hookSpecificOutput.permissionDecisionReason' <<< "${mixed_output}")
151
+ grep -q 'evil-pkg@9.9.9' <<< "${reason}" || fail "deny reason names the real package"
152
+ [[ "${reason}" != *"dev1@block-s.io"* ]] || fail "deny reason must not name the email arg"
153
+ pass "hook gates real install chained with npx run (email not polluted)"
154
+
155
+ echo_output=$(
156
+ run_hook_command "${tmp_root}/home-echo" "${tmp_root}/safe-echo" "echo \"npm install evil-pkg@9.9.9\""
157
+ )
158
+ [[ -z "${echo_output}" ]] || fail "hook ignores quoted echo text"
159
+ heredoc_output=$(
160
+ run_hook_command "${tmp_root}/home-heredoc" "${tmp_root}/safe-heredoc" $'cat <<'\''EOF'\''\nnpm install evil-pkg@9.9.9\nEOF'
161
+ )
162
+ [[ -z "${heredoc_output}" ]] || fail "hook ignores heredoc body text"
163
+ pass "hook ignores echo/heredoc text"
164
+
165
+ bypass_cases=(
166
+ "/usr/bin/npm install evil@1.2.3"
167
+ "bash -lc \"npm install evil@1.2.3\""
168
+ "env npm install evil@1.2.3"
169
+ "command npm install evil@1.2.3"
170
+ "npm --prefix sub install evil@1.2.3"
171
+ "pip install requests==2.31.0"
172
+ "gem install rails -v 7.1.0"
173
+ "cargo add serde --vers 1.0.0"
174
+ "dotnet add package X --version 1.0.0"
175
+ )
176
+ for bypass_cmd in "${bypass_cases[@]}"; do
177
+ bypass_output=$(run_hook_command "${tmp_root}/home-bypass" "${tmp_root}/safe-bypass" "${bypass_cmd}")
178
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${bypass_output}")" == "deny" ]] || fail "hook denies bypass: ${bypass_cmd}"
179
+ done
180
+ pass "hook denies install bypass forms"
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
+ tamper_safe="${tmp_root}/safe-tamper"
228
+ tamper_home="${tmp_root}/home-tamper"
229
+ SAFEDEPS_HOME="${tamper_safe}" lib/ledger/ledger.sh approve npm ledger-tamper 1.0.0 1.0.0 smoke >/dev/null
230
+ tamper_pre=$(run_hook_command "${tamper_home}" "${tamper_safe}" "npm install ledger-tamper@1.0.0")
231
+ [[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<< "${tamper_pre}")" == "allow" ]] || fail "tamper fixture pre hook allows approved install"
232
+ mkdir -p "${project_dir}/node_modules/ledger-tamper"
233
+ jq '.dependencies["ledger-tamper"]="1.0.0"' "${project_dir}/package.json" > "${project_dir}/package.json.tmp"
234
+ mv "${project_dir}/package.json.tmp" "${project_dir}/package.json"
235
+ cat > "${project_dir}/node_modules/ledger-tamper/package.json" <<'EOF'
236
+ {"name":"ledger-tamper","version":"1.0.0","scripts":{"postinstall":"node -e \"require('fs').writeFileSync(process.env.HOME + '/.safedeps/approved-specs/evil.json', '{}')\""}}
73
237
  EOF
238
+ tamper_post=$(
239
+ jq -nc '{tool_name:"Bash",tool_input:{command:"npm install ledger-tamper@1.0.0"}}' |
240
+ HOME="${tamper_home}" SAFEDEPS_HOME="${tamper_safe}" scripts/safedeps-post-verify.sh
74
241
  )
75
- [[ -z "${allow_output}" ]] || fail "hook allows approved install"
76
- pass "hook allows approved install"
242
+ grep -q '의심스러운 패키지 변경 감지' <<< "${tamper_post}" || fail "post hook reorgs safedeps ledger tamper script"
243
+ pass "post hook reorgs safedeps ledger tamper script"
77
244
 
78
245
  fixture_json="${tmp_root}/recheck-fixture.json"
79
246
  printf '%s\n' '{"command":"re-check","checked":2,"still_clean":1,"newly_vulnerable":[],"kev_hit":[],"revoked":[]}' > "${fixture_json}"
@@ -86,4 +253,39 @@ grep -q '"checked":2' "${tmp_root}/safe-recheck/recheck.log" || fail "re-check w
86
253
  grep -q '"provider_skipped":1' "${tmp_root}/safe-recheck/recheck-alerts.jsonl" || fail "re-check wrapper alerts on skipped provider checks"
87
254
  pass "re-check alert wrapper"
88
255
 
256
+ # Release-time lane (absorbed from security-release-gates): commands must be
257
+ # registered and resolve their gate scripts.
258
+ gates_help=$(HOME="${tmp_root}/home-gates" SAFEDEPS_HOME="${tmp_root}/safe-gates" ./bin/safedeps help)
259
+ for gate_cmd in "gates" "scan secrets" "audit" "hooks" "doctor"; do
260
+ grep -q "${gate_cmd}" <<< "${gates_help}" || fail "release-time command listed in help: ${gate_cmd}"
261
+ done
262
+ 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
263
+ [[ -f "${gate_script}" ]] || fail "release-time gate script present: ${gate_script}"
264
+ done
265
+ for tmpl in gitleaks.toml.tmpl gitleaks.private.toml.tmpl pre-commit.tmpl; do
266
+ [[ -f "lib/gates/templates/${tmpl}" ]] || fail "secret-lane template present: ${tmpl}"
267
+ done
268
+ pass "release-time gate commands registered"
269
+
270
+ # Secret-leak lane: doctor diagnoses, hooks init scaffolds, hooks install
271
+ # activates. No scanner (gitleaks/docker) needed for these structural checks.
272
+ doctor_repo=$(mktemp -d "${tmp_root}/secret-repo.XXXXXX")
273
+ git -C "${doctor_repo}" init -q
274
+ # doctor exits 1 when gaps exist; capture the JSON without tripping set -e.
275
+ doctor_json=$(HOME="${tmp_root}/home-doctor" ./bin/safedeps --json doctor --root "${doctor_repo}" || true)
276
+ [[ "$(jq -r '.command' <<< "${doctor_json}")" == "doctor" ]] || fail "doctor --json command field"
277
+ [[ "$(jq -r '.ok' <<< "${doctor_json}")" == "false" ]] || fail "doctor reports gaps on a bare repo"
278
+ secret_gaps=$(jq -r '[.checks[] | select(.lane == "secret" and .status == "gap")] | length' <<< "${doctor_json}")
279
+ [[ "${secret_gaps}" -ge 3 ]] || fail "doctor lists at least 3 secret-lane gaps (got ${secret_gaps})"
280
+ HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks init --root "${doctor_repo}" >/dev/null
281
+ [[ -f "${doctor_repo}/.gitleaks.toml" ]] || fail "hooks init scaffolds .gitleaks.toml"
282
+ [[ -x "${doctor_repo}/.githooks/pre-commit" ]] || fail "hooks init scaffolds an executable pre-commit"
283
+ grep -q 'scan secrets --staged' "${doctor_repo}/.githooks/pre-commit" || fail "pre-commit delegates to safedeps scan"
284
+ printf '\n# repo-owned edit marker\n' >> "${doctor_repo}/.gitleaks.toml"
285
+ HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks init --root "${doctor_repo}" >/dev/null
286
+ grep -q 'repo-owned edit marker' "${doctor_repo}/.gitleaks.toml" || fail "hooks init is non-destructive (keeps repo edits)"
287
+ HOME="${tmp_root}/home-doctor" ./bin/safedeps hooks install --root "${doctor_repo}" >/dev/null
288
+ [[ "$(git -C "${doctor_repo}" config --get core.hooksPath)" == ".githooks" ]] || fail "hooks install activates core.hooksPath"
289
+ pass "doctor + hooks init/install wire the secret lane (non-destructive)"
290
+
89
291
  printf 'smoke passed\n'