@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.
@@ -0,0 +1,479 @@
1
+ #!/usr/bin/env bash
2
+ # Safedeps provider adapters.
3
+ # OSV is the canonical advisory truth; KEV/GHSA only add observable signals.
4
+
5
+ set -euo pipefail
6
+
7
+ SAFEDEPS_HOME="${SAFEDEPS_HOME:-${HOME}/.safedeps}"
8
+ SAFEDEPS_CACHE_DIR="${SAFEDEPS_CACHE_DIR:-${SAFEDEPS_HOME}/cache}"
9
+ SAFEDEPS_ADVISORY_LOG="${SAFEDEPS_ADVISORY_LOG:-${SAFEDEPS_HOME}/advisory.log}"
10
+ SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS="${SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS:-86400}"
11
+
12
+ SAFEDEPS_OSV_API_URL="${SAFEDEPS_OSV_API_URL:-https://api.osv.dev/v1/query}"
13
+ SAFEDEPS_KEV_CATALOG_URL="${SAFEDEPS_KEV_CATALOG_URL:-https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json}"
14
+ SAFEDEPS_GHSA_API_URL="${SAFEDEPS_GHSA_API_URL:-https://api.github.com/advisories}"
15
+
16
+ safedeps_providers_init() {
17
+ umask 077
18
+ mkdir -p \
19
+ "${SAFEDEPS_CACHE_DIR}/osv" \
20
+ "${SAFEDEPS_CACHE_DIR}/kev" \
21
+ "${SAFEDEPS_CACHE_DIR}/ghsa" \
22
+ "$(dirname "${SAFEDEPS_ADVISORY_LOG}")"
23
+ }
24
+
25
+ safedeps_provider_log() {
26
+ local level="$1"
27
+ local message="$2"
28
+
29
+ safedeps_providers_init
30
+ printf '[%s] %s %s\n' "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" "${level}" "${message}" >> "${SAFEDEPS_ADVISORY_LOG}"
31
+ }
32
+
33
+ safedeps_require_json_tools() {
34
+ if ! command -v jq >/dev/null 2>&1; then
35
+ printf 'safedeps providers: jq is required\n' >&2
36
+ return 1
37
+ fi
38
+ }
39
+
40
+ safedeps_require_http_client() {
41
+ if ! command -v curl >/dev/null 2>&1; then
42
+ printf 'safedeps providers: curl is required for provider queries\n' >&2
43
+ return 1
44
+ fi
45
+ }
46
+
47
+ safedeps_provider_mktemp_dir() {
48
+ local tmp_root="${TMPDIR:-/tmp}"
49
+
50
+ mkdir -p "${tmp_root}" || return 1
51
+ mktemp -d "${tmp_root%/}/safedeps-providers.XXXXXX"
52
+ }
53
+
54
+ safedeps_now_iso() {
55
+ date -u +"%Y-%m-%dT%H:%M:%SZ"
56
+ }
57
+
58
+ safedeps_hash_text() {
59
+ local input="$1"
60
+
61
+ if command -v shasum >/dev/null 2>&1; then
62
+ printf '%s' "${input}" | shasum -a 256 | cut -d' ' -f1
63
+ elif command -v sha256sum >/dev/null 2>&1; then
64
+ printf '%s' "${input}" | sha256sum | cut -d' ' -f1
65
+ else
66
+ printf 'safedeps providers: shasum or sha256sum is required\n' >&2
67
+ return 1
68
+ fi
69
+ }
70
+
71
+ safedeps_file_mtime() {
72
+ local path="$1"
73
+
74
+ stat -f %m "${path}" 2>/dev/null || stat -c %Y "${path}" 2>/dev/null
75
+ }
76
+
77
+ safedeps_cache_is_fresh() {
78
+ local path="$1"
79
+ local ttl="${2:-${SAFEDEPS_PROVIDER_CACHE_TTL_SECONDS}}"
80
+ local now
81
+ local mtime
82
+
83
+ [[ -f "${path}" ]] || return 1
84
+ now=$(date +%s)
85
+ mtime=$(safedeps_file_mtime "${path}") || return 1
86
+ [[ $(( now - mtime )) -le "${ttl}" ]]
87
+ }
88
+
89
+ safedeps_cache_key() {
90
+ local namespace="$1"
91
+ local ecosystem="$2"
92
+ local package_name="$3"
93
+ local version="$4"
94
+
95
+ safedeps_hash_text "${namespace}
96
+ ${ecosystem}
97
+ ${package_name}
98
+ ${version}"
99
+ }
100
+
101
+ safedeps_json_uri_escape() {
102
+ jq -nr --arg value "$1" '$value | @uri'
103
+ }
104
+
105
+ safedeps_osv_ecosystem() {
106
+ case "$1" in
107
+ npm|NPM) printf 'npm' ;;
108
+ pypi|PyPI|pip) printf 'PyPI' ;;
109
+ crates.io|cargo|rust) printf 'crates.io' ;;
110
+ go|golang|Go) printf 'Go' ;;
111
+ rubygems|gem|ruby|RubyGems) printf 'RubyGems' ;;
112
+ maven|Maven) printf 'Maven' ;;
113
+ nuget|NuGet) printf 'NuGet' ;;
114
+ *) printf '%s' "$1" ;;
115
+ esac
116
+ }
117
+
118
+ safedeps_ghsa_ecosystem() {
119
+ case "$1" in
120
+ npm|NPM) printf 'npm' ;;
121
+ pypi|PyPI|pip) printf 'pip' ;;
122
+ crates.io|cargo|rust) printf 'rust' ;;
123
+ go|golang|Go) printf 'go' ;;
124
+ rubygems|gem|ruby|RubyGems) printf 'rubygems' ;;
125
+ maven|Maven) printf 'maven' ;;
126
+ nuget|NuGet) printf 'nuget' ;;
127
+ *) printf '%s' "$1" ;;
128
+ esac
129
+ }
130
+
131
+ safedeps_osv_query() {
132
+ local ecosystem="$1"
133
+ local package_name="$2"
134
+ local version="$3"
135
+ local osv_ecosystem
136
+ local cache_key
137
+ local cache_path
138
+ local payload
139
+ local response_file
140
+ local http_status
141
+
142
+ safedeps_require_json_tools || return 1
143
+ safedeps_providers_init
144
+
145
+ osv_ecosystem=$(safedeps_osv_ecosystem "${ecosystem}")
146
+ cache_key=$(safedeps_cache_key "osv" "${osv_ecosystem}" "${package_name}" "${version}")
147
+ cache_path="${SAFEDEPS_CACHE_DIR}/osv/${cache_key}.json"
148
+
149
+ if safedeps_cache_is_fresh "${cache_path}"; then
150
+ safedeps_provider_log "INFO" "OSV cache hit ecosystem=${osv_ecosystem} package=${package_name} version=${version}"
151
+ cat "${cache_path}"
152
+ return 0
153
+ fi
154
+
155
+ safedeps_require_http_client || {
156
+ if [[ -f "${cache_path}" ]]; then
157
+ safedeps_provider_log "ERROR" "OSV unavailable; stale cache refused ecosystem=${osv_ecosystem} package=${package_name} version=${version}"
158
+ else
159
+ safedeps_provider_log "ERROR" "OSV unavailable; cache miss ecosystem=${osv_ecosystem} package=${package_name} version=${version}"
160
+ fi
161
+ return 1
162
+ }
163
+
164
+ payload=$(jq -cn \
165
+ --arg ecosystem "${osv_ecosystem}" \
166
+ --arg package "${package_name}" \
167
+ --arg version "${version}" \
168
+ '{version: $version, package: {name: $package, ecosystem: $ecosystem}}')
169
+
170
+ response_file="${cache_path}.$$"
171
+ http_status=$(curl -fsS \
172
+ --max-time 15 \
173
+ -H 'Content-Type: application/json' \
174
+ -o "${response_file}" \
175
+ -w '%{http_code}' \
176
+ -d "${payload}" \
177
+ "${SAFEDEPS_OSV_API_URL}" 2>/dev/null || true)
178
+
179
+ if [[ "${http_status}" == "200" ]] && jq -e 'type == "object"' "${response_file}" >/dev/null 2>&1; then
180
+ mv "${response_file}" "${cache_path}"
181
+ safedeps_provider_log "INFO" "OSV live query ok ecosystem=${osv_ecosystem} package=${package_name} version=${version}"
182
+ cat "${cache_path}"
183
+ return 0
184
+ fi
185
+
186
+ rm -f "${response_file}"
187
+ if [[ -f "${cache_path}" ]]; then
188
+ safedeps_provider_log "ERROR" "OSV live query failed; stale cache refused ecosystem=${osv_ecosystem} package=${package_name} version=${version} status=${http_status:-none}"
189
+ else
190
+ safedeps_provider_log "ERROR" "OSV live query failed; cache miss ecosystem=${osv_ecosystem} package=${package_name} version=${version} status=${http_status:-none}"
191
+ fi
192
+ return 1
193
+ }
194
+
195
+ safedeps_extract_cves_from_osv() {
196
+ local osv_json="$1"
197
+
198
+ jq -r '
199
+ [
200
+ .vulns[]? |
201
+ (.id // empty),
202
+ (.aliases[]? // empty)
203
+ ]
204
+ | map(select(test("^CVE-[0-9]{4}-[0-9]+$")))
205
+ | unique
206
+ | .[]
207
+ ' "${osv_json}"
208
+ }
209
+
210
+ safedeps_kev_catalog_path() {
211
+ printf '%s/kev/known_exploited_vulnerabilities.json' "${SAFEDEPS_CACHE_DIR}"
212
+ }
213
+
214
+ safedeps_kev_refresh_catalog() {
215
+ local cache_path
216
+ local response_path
217
+ local http_status
218
+
219
+ safedeps_require_json_tools || return 1
220
+ safedeps_providers_init
221
+ cache_path=$(safedeps_kev_catalog_path)
222
+
223
+ if safedeps_cache_is_fresh "${cache_path}"; then
224
+ printf '%s' "${cache_path}"
225
+ return 0
226
+ fi
227
+
228
+ if ! safedeps_require_http_client; then
229
+ [[ -f "${cache_path}" ]] && printf '%s' "${cache_path}" && return 0
230
+ return 1
231
+ fi
232
+
233
+ response_path="${cache_path}.$$"
234
+ http_status=$(curl -fsS --max-time 15 -o "${response_path}" -w '%{http_code}' "${SAFEDEPS_KEV_CATALOG_URL}" 2>/dev/null || true)
235
+
236
+ if [[ "${http_status}" == "200" ]] && jq -e '.vulnerabilities | type == "array"' "${response_path}" >/dev/null 2>&1; then
237
+ mv "${response_path}" "${cache_path}"
238
+ safedeps_provider_log "INFO" "CISA KEV catalog refresh ok"
239
+ printf '%s' "${cache_path}"
240
+ return 0
241
+ fi
242
+
243
+ rm -f "${response_path}"
244
+ if [[ -f "${cache_path}" ]]; then
245
+ safedeps_provider_log "WARN" "CISA KEV refresh failed; using stale local catalog status=${http_status:-none}"
246
+ printf '%s' "${cache_path}"
247
+ return 0
248
+ fi
249
+
250
+ safedeps_provider_log "WARN" "CISA KEV unavailable and no local catalog status=${http_status:-none}"
251
+ return 1
252
+ }
253
+
254
+ safedeps_kev_overlay() {
255
+ local osv_json="$1"
256
+ local queried_at="$2"
257
+ local catalog_path
258
+ local cve_array
259
+ local status="ok"
260
+ local warning=""
261
+
262
+ cve_array=$(safedeps_extract_cves_from_osv "${osv_json}" | jq -R . | jq -s .)
263
+
264
+ if ! catalog_path=$(safedeps_kev_refresh_catalog); then
265
+ jq -cn --arg queried_at "${queried_at}" --argjson cves "${cve_array}" \
266
+ '{queried_at: $queried_at, status: "unavailable", warning: "CISA KEV catalog unavailable", cves_checked: $cves, exploited: false, matches: []}'
267
+ return 0
268
+ fi
269
+
270
+ if ! safedeps_cache_is_fresh "${catalog_path}"; then
271
+ status="stale"
272
+ warning="CISA KEV catalog is older than provider TTL"
273
+ fi
274
+
275
+ jq -cn \
276
+ --arg queried_at "${queried_at}" \
277
+ --arg status "${status}" \
278
+ --arg warning "${warning}" \
279
+ --argjson cves "${cve_array}" \
280
+ --slurpfile catalog "${catalog_path}" '
281
+ ($catalog[0].vulnerabilities // []) as $items
282
+ | ($items | map(select(.cveID as $id | $cves | index($id)))) as $matches
283
+ | {
284
+ queried_at: $queried_at,
285
+ status: $status,
286
+ warning: (if $warning == "" then null else $warning end),
287
+ cves_checked: $cves,
288
+ exploited: (($matches | length) > 0),
289
+ matches: $matches
290
+ }'
291
+ }
292
+
293
+ safedeps_ghsa_query() {
294
+ local ecosystem="$1"
295
+ local package_name="$2"
296
+ local queried_at="$3"
297
+ local ghsa_ecosystem
298
+ local encoded_ecosystem
299
+ local encoded_package
300
+ local cache_key
301
+ local cache_path
302
+ local response_file
303
+ local http_status
304
+
305
+ safedeps_require_json_tools || return 1
306
+ safedeps_providers_init
307
+
308
+ ghsa_ecosystem=$(safedeps_ghsa_ecosystem "${ecosystem}")
309
+ cache_key=$(safedeps_cache_key "ghsa" "${ghsa_ecosystem}" "${package_name}" "all")
310
+ cache_path="${SAFEDEPS_CACHE_DIR}/ghsa/${cache_key}.json"
311
+
312
+ if safedeps_cache_is_fresh "${cache_path}"; then
313
+ jq -cn --arg queried_at "${queried_at}" --slurpfile advisories "${cache_path}" \
314
+ '{queried_at: $queried_at, status: "cache_hit", advisories: $advisories[0]}'
315
+ return 0
316
+ fi
317
+
318
+ if ! safedeps_require_http_client; then
319
+ safedeps_provider_log "WARN" "GHSA skipped; curl unavailable ecosystem=${ghsa_ecosystem} package=${package_name}"
320
+ jq -cn --arg queried_at "${queried_at}" \
321
+ '{queried_at: $queried_at, status: "skipped", warning: "curl unavailable", advisories: []}'
322
+ return 0
323
+ fi
324
+
325
+ encoded_ecosystem=$(safedeps_json_uri_escape "${ghsa_ecosystem}")
326
+ encoded_package=$(safedeps_json_uri_escape "${package_name}")
327
+ response_file="${cache_path}.$$"
328
+
329
+ if [[ -n "${GITHUB_TOKEN:-}" ]]; then
330
+ http_status=$(curl -fsS \
331
+ --max-time 15 \
332
+ -H 'Accept: application/vnd.github+json' \
333
+ -H 'X-GitHub-Api-Version: 2022-11-28' \
334
+ -H "Authorization: Bearer ${GITHUB_TOKEN}" \
335
+ -o "${response_file}" \
336
+ -w '%{http_code}' \
337
+ "${SAFEDEPS_GHSA_API_URL}?ecosystem=${encoded_ecosystem}&affects=${encoded_package}&per_page=100" 2>/dev/null || true)
338
+ else
339
+ http_status=$(curl -fsS \
340
+ --max-time 15 \
341
+ -H 'Accept: application/vnd.github+json' \
342
+ -H 'X-GitHub-Api-Version: 2022-11-28' \
343
+ -o "${response_file}" \
344
+ -w '%{http_code}' \
345
+ "${SAFEDEPS_GHSA_API_URL}?ecosystem=${encoded_ecosystem}&affects=${encoded_package}&per_page=100" 2>/dev/null || true)
346
+ fi
347
+
348
+ if [[ "${http_status}" == "200" ]] && jq -e 'type == "array"' "${response_file}" >/dev/null 2>&1; then
349
+ mv "${response_file}" "${cache_path}"
350
+ safedeps_provider_log "INFO" "GHSA live query ok ecosystem=${ghsa_ecosystem} package=${package_name}"
351
+ jq -cn --arg queried_at "${queried_at}" --slurpfile advisories "${cache_path}" \
352
+ '{queried_at: $queried_at, status: "live", advisories: $advisories[0]}'
353
+ return 0
354
+ fi
355
+
356
+ rm -f "${response_file}"
357
+ safedeps_provider_log "WARN" "GHSA cross-check skipped ecosystem=${ghsa_ecosystem} package=${package_name} status=${http_status:-none}"
358
+ jq -cn --arg queried_at "${queried_at}" --arg status "${http_status:-none}" \
359
+ '{queried_at: $queried_at, status: "skipped", warning: ("GHSA cross-check skipped; HTTP status " + $status), advisories: []}'
360
+ }
361
+
362
+ safedeps_providers_query() {
363
+ local ecosystem="$1"
364
+ local package_name="$2"
365
+ local version="$3"
366
+ local queried_at
367
+ local osv_file
368
+ local temp_dir
369
+ local kev_file
370
+ local ghsa_file
371
+
372
+ safedeps_require_json_tools || return 1
373
+ queried_at=$(safedeps_now_iso)
374
+ if ! temp_dir=$(safedeps_provider_mktemp_dir); then
375
+ safedeps_provider_log "ERROR" "provider temp dir creation failed tmpdir=${TMPDIR:-/tmp}"
376
+ jq -cn \
377
+ --arg ecosystem "${ecosystem}" \
378
+ --arg package "${package_name}" \
379
+ --arg version "${version}" \
380
+ --arg queried_at "${queried_at}" \
381
+ '{
382
+ ecosystem: $ecosystem,
383
+ package: $package,
384
+ version: $version,
385
+ queried_at: $queried_at,
386
+ status: "blocked",
387
+ reason: "provider temp dir creation failed",
388
+ vulnerabilities: [],
389
+ kev: {queried_at: $queried_at, status: "not_queried", exploited: false, matches: []},
390
+ advisories: [],
391
+ provider_status: {
392
+ osv: {status: "failed_closed"},
393
+ kev: {status: "not_queried"},
394
+ ghsa: {status: "not_queried"}
395
+ }
396
+ }'
397
+ return 1
398
+ fi
399
+
400
+ osv_file="${temp_dir}/osv.json"
401
+ kev_file="${temp_dir}/kev.json"
402
+ ghsa_file="${temp_dir}/ghsa.json"
403
+
404
+ if ! safedeps_osv_query "${ecosystem}" "${package_name}" "${version}" > "${osv_file}"; then
405
+ jq -cn \
406
+ --arg ecosystem "${ecosystem}" \
407
+ --arg package "${package_name}" \
408
+ --arg version "${version}" \
409
+ --arg queried_at "${queried_at}" \
410
+ '{
411
+ ecosystem: $ecosystem,
412
+ package: $package,
413
+ version: $version,
414
+ queried_at: $queried_at,
415
+ status: "blocked",
416
+ reason: "OSV primary provider unavailable and no fresh cache",
417
+ vulnerabilities: [],
418
+ kev: {queried_at: $queried_at, status: "not_queried", exploited: false, matches: []},
419
+ advisories: [],
420
+ provider_status: {
421
+ osv: {status: "failed_closed"},
422
+ kev: {status: "not_queried"},
423
+ ghsa: {status: "not_queried"}
424
+ }
425
+ }'
426
+ rm -rf "${temp_dir}"
427
+ return 1
428
+ fi
429
+
430
+ safedeps_kev_overlay "${osv_file}" "${queried_at}" > "${kev_file}"
431
+ safedeps_ghsa_query "${ecosystem}" "${package_name}" "${queried_at}" > "${ghsa_file}"
432
+
433
+ jq -cn \
434
+ --arg ecosystem "${ecosystem}" \
435
+ --arg package "${package_name}" \
436
+ --arg version "${version}" \
437
+ --arg queried_at "${queried_at}" \
438
+ --slurpfile osv "${osv_file}" \
439
+ --slurpfile kev "${kev_file}" \
440
+ --slurpfile ghsa "${ghsa_file}" '
441
+ ($osv[0].vulns // []) as $vulns
442
+ | ($kev[0]) as $kev_result
443
+ | ($ghsa[0]) as $ghsa_result
444
+ | {
445
+ ecosystem: $ecosystem,
446
+ package: $package,
447
+ version: $version,
448
+ queried_at: $queried_at,
449
+ status: (if $kev_result.exploited then "hard_block" elif ($vulns | length) > 0 then "vulnerable" else "clean" end),
450
+ vulnerabilities: $vulns,
451
+ kev: $kev_result,
452
+ advisories: ($ghsa_result.advisories // []),
453
+ provider_status: {
454
+ osv: {status: "ok", canonical: true},
455
+ kev: {status: ($kev_result.status // "ok"), overlay: true},
456
+ ghsa: {status: ($ghsa_result.status // "ok"), enrichment: true}
457
+ }
458
+ }'
459
+ rm -rf "${temp_dir}"
460
+ }
461
+
462
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
463
+ command_name="${1:-}"
464
+ shift || true
465
+
466
+ case "${command_name}" in
467
+ query)
468
+ if [[ "$#" -ne 3 ]]; then
469
+ printf 'usage: %s query <ecosystem> <package> <version>\n' "$0" >&2
470
+ exit 2
471
+ fi
472
+ safedeps_providers_query "$@"
473
+ ;;
474
+ *)
475
+ printf 'usage: %s query <ecosystem> <package> <version>\n' "$0" >&2
476
+ exit 2
477
+ ;;
478
+ esac
479
+ fi
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@aldegad/safedeps",
3
+ "version": "2.1.0",
4
+ "description": "Dependency install safety gate with OSV-backed advisory checks, approved-spec ledger enforcement, and reorg rollback hooks",
5
+ "main": "bin/safedeps",
6
+ "bin": {
7
+ "safedeps": "bin/safedeps"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "lib/",
12
+ "scripts/",
13
+ "agents/",
14
+ "README.md",
15
+ "ARCHITECTURE.md",
16
+ "ROADMAP.md",
17
+ "SKILL.md",
18
+ "LICENSE"
19
+ ],
20
+ "scripts": {
21
+ "test": "bash scripts/test/smoke.sh && bash scripts/test/e2e.sh"
22
+ },
23
+ "keywords": [
24
+ "safedeps",
25
+ "security",
26
+ "supply-chain",
27
+ "advisory",
28
+ "osv",
29
+ "reorg",
30
+ "claude-code",
31
+ "hooks",
32
+ "package-lock",
33
+ "rollback"
34
+ ],
35
+ "author": "soohongkim",
36
+ "license": "Apache-2.0",
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "git+https://github.com/aldegad/safedeps.git"
40
+ }
41
+ }