@wipcomputer/wip-ldm-os 0.4.85-alpha.2 → 0.4.85-alpha.21
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/README.md +22 -2
- package/SKILL.md +8 -5
- package/bin/ldm.js +169 -65
- package/docs/universal-installer/SPEC.md +16 -3
- package/docs/universal-installer/TECHNICAL.md +4 -4
- package/lib/deploy.mjs +104 -20
- package/lib/detect.mjs +35 -4
- package/package.json +13 -2
- package/scripts/test-crc-agentid-tenant-boundary.mjs +80 -0
- package/scripts/test-crc-e2ee-key-persistence.mjs +150 -0
- package/scripts/test-crc-e2ee-session-route.mjs +129 -0
- package/scripts/test-crc-pair-login-flow.mjs +40 -0
- package/scripts/test-crc-pair-relink-audit-and-rotation.mjs +164 -0
- package/scripts/test-crc-pair-status-poll-token.mjs +73 -0
- package/scripts/test-install-prompt-policy.mjs +60 -0
- package/scripts/test-installer-skill-directory.mjs +55 -0
- package/scripts/test-installer-skill-dry-run-destinations.mjs +100 -0
- package/scripts/test-installer-target-self-update.mjs +131 -0
- package/scripts/test-ldm-status-timeout.mjs +80 -0
- package/shared/templates/install-prompt.md +20 -2
- package/src/hosted-mcp/README.md +15 -0
- package/src/hosted-mcp/app/footer.js +74 -0
- package/src/hosted-mcp/app/kaleidoscope-login.html +846 -0
- package/src/hosted-mcp/app/pair.html +165 -57
- package/src/hosted-mcp/app/sprites.png +0 -0
- package/src/hosted-mcp/codex-relay-e2ee-registry.mjs +208 -0
- package/src/hosted-mcp/demo/index.html +3 -7
- package/src/hosted-mcp/demo/login.html +318 -20
- package/src/hosted-mcp/deploy.sh +307 -56
- package/src/hosted-mcp/docs/self-host.md +268 -0
- package/src/hosted-mcp/nginx/codex-relay.conf +25 -0
- package/src/hosted-mcp/nginx/conf.d/redact-logs.conf +60 -0
- package/src/hosted-mcp/nginx/mcp-oauth.conf +58 -0
- package/src/hosted-mcp/nginx/wip.computer.conf +25 -1
- package/src/hosted-mcp/scripts/audit-logs.sh +205 -0
- package/src/hosted-mcp/scripts/verify-deploy.sh +102 -0
- package/src/hosted-mcp/server.mjs +963 -146
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# nginx log redaction (F-007 in the VPS hosted-mcp audit).
|
|
2
|
+
#
|
|
3
|
+
# Defines a "redacted" log_format that masks bearer tokens, WS tickets,
|
|
4
|
+
# and ck-style API keys in the request line and the Referer header
|
|
5
|
+
# before they are written to access logs. Maps run at http context, so
|
|
6
|
+
# this file deploys to /etc/nginx/conf.d/redact-logs.conf where nginx
|
|
7
|
+
# loads it automatically from inside the http {} block.
|
|
8
|
+
#
|
|
9
|
+
# Source: src/hosted-mcp/nginx/conf.d/redact-logs.conf
|
|
10
|
+
# Deploy: /etc/nginx/conf.d/redact-logs.conf
|
|
11
|
+
#
|
|
12
|
+
# To use this format, server blocks must reference it explicitly on
|
|
13
|
+
# their access_log directive, e.g.
|
|
14
|
+
# access_log /var/log/nginx/wip.computer.access.log redacted;
|
|
15
|
+
|
|
16
|
+
# ── Query-string redaction ──────────────────────────────────────────
|
|
17
|
+
#
|
|
18
|
+
# nginx maps replace the entire value, not a substring, so any value
|
|
19
|
+
# containing one of these patterns gets the whole query string masked.
|
|
20
|
+
# That is correct here: if a request is leaking a token in the query,
|
|
21
|
+
# we do not want to keep the rest of the args either.
|
|
22
|
+
|
|
23
|
+
# Note: regex patterns are double-quoted because nginx's lexer treats
|
|
24
|
+
# bare {N,} quantifiers as config-block delimiters. Quoting forces
|
|
25
|
+
# nginx to treat the whole token as a regex string.
|
|
26
|
+
|
|
27
|
+
map $args $args_redacted {
|
|
28
|
+
"~*(?:^|&)token=" "[REDACTED token=]";
|
|
29
|
+
"~*(?:^|&)ticket=" "[REDACTED ticket=]";
|
|
30
|
+
"~*(?:^|&)api_key=" "[REDACTED api_key=]";
|
|
31
|
+
"~*(?:^|&)access_token=" "[REDACTED access_token=]";
|
|
32
|
+
"~*ck-[A-Za-z0-9_-]{6,}" "[REDACTED ck-]";
|
|
33
|
+
default $args;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# ── Referer redaction ───────────────────────────────────────────────
|
|
37
|
+
#
|
|
38
|
+
# Browsers send the previous URL as Referer, which can carry token
|
|
39
|
+
# values across navigations. Mask the same patterns there.
|
|
40
|
+
|
|
41
|
+
map $http_referer $http_referer_redacted {
|
|
42
|
+
"~*[?&]token=" "[REDACTED token in referer]";
|
|
43
|
+
"~*[?&]ticket=" "[REDACTED ticket in referer]";
|
|
44
|
+
"~*[?&]api_key=" "[REDACTED api_key in referer]";
|
|
45
|
+
"~*[?&]access_token=" "[REDACTED access_token in referer]";
|
|
46
|
+
"~*ck-[A-Za-z0-9_-]{6,}" "[REDACTED ck in referer]";
|
|
47
|
+
default $http_referer;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# ── Request line built without the raw query ────────────────────────
|
|
51
|
+
#
|
|
52
|
+
# $request expands to the full request line including the raw query.
|
|
53
|
+
# We rebuild it from $request_method + $uri (path only) + the redacted
|
|
54
|
+
# args, so the path is preserved but the query is masked when it
|
|
55
|
+
# contains a token-shaped value.
|
|
56
|
+
|
|
57
|
+
log_format redacted '$remote_addr - $remote_user [$time_local] '
|
|
58
|
+
'"$request_method $uri?$args_redacted $server_protocol" '
|
|
59
|
+
'$status $body_bytes_sent '
|
|
60
|
+
'"$http_referer_redacted" "$http_user_agent"';
|
|
@@ -37,6 +37,27 @@ location /webauthn/ {
|
|
|
37
37
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
# QR login API used by the canonical /login Kaleidoscope desktop-to-phone
|
|
41
|
+
# flow. Without these routes nginx falls through to the static homepage
|
|
42
|
+
# or returns 405, and the browser tries to parse HTML as JSON.
|
|
43
|
+
location = /api/qr-login {
|
|
44
|
+
proxy_pass http://127.0.0.1:18800/api/qr-login;
|
|
45
|
+
proxy_http_version 1.1;
|
|
46
|
+
proxy_set_header Host $host;
|
|
47
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
48
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
49
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
location /api/qr-login/ {
|
|
53
|
+
proxy_pass http://127.0.0.1:18800/api/qr-login/;
|
|
54
|
+
proxy_http_version 1.1;
|
|
55
|
+
proxy_set_header Host $host;
|
|
56
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
57
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
58
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
59
|
+
}
|
|
60
|
+
|
|
40
61
|
# Signup and login pages
|
|
41
62
|
location = /signup {
|
|
42
63
|
proxy_pass http://127.0.0.1:18800/signup;
|
|
@@ -65,6 +86,43 @@ location = /login {
|
|
|
65
86
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
66
87
|
}
|
|
67
88
|
|
|
89
|
+
# Explicit non-primary route for the app/login.html flow. Without
|
|
90
|
+
# this, /login/app falls through to the catch-all `location /` and
|
|
91
|
+
# serves the WIP marketing static. The Node app's /login/app handler
|
|
92
|
+
# is what we actually want here.
|
|
93
|
+
location = /login/app {
|
|
94
|
+
proxy_pass http://127.0.0.1:18800/login/app;
|
|
95
|
+
proxy_http_version 1.1;
|
|
96
|
+
proxy_set_header Host $host;
|
|
97
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
98
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
99
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
100
|
+
}
|
|
101
|
+
location = /login/app/ {
|
|
102
|
+
proxy_pass http://127.0.0.1:18800/login/app/;
|
|
103
|
+
proxy_http_version 1.1;
|
|
104
|
+
proxy_set_header Host $host;
|
|
105
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
106
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
107
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# Static assets served by Node from src/hosted-mcp/app/.
|
|
111
|
+
# /login (app/kaleidoscope-login.html) references /app/footer.js and
|
|
112
|
+
# /app/sprites.png. Without this proxy, those asset requests fall
|
|
113
|
+
# through to the catch-all `location /` and either 404 or get the
|
|
114
|
+
# static homepage HTML, recreating the "HTML returned where JS/PNG
|
|
115
|
+
# was expected" failure class. server.mjs's serveAppFile sets the
|
|
116
|
+
# correct Content-Type for js, png, html, css, svg, json, ico.
|
|
117
|
+
location /app/ {
|
|
118
|
+
proxy_pass http://127.0.0.1:18800/app/;
|
|
119
|
+
proxy_http_version 1.1;
|
|
120
|
+
proxy_set_header Host $host;
|
|
121
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
122
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
123
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
124
|
+
}
|
|
125
|
+
|
|
68
126
|
# Agent approval page (server-generated)
|
|
69
127
|
location = /approve {
|
|
70
128
|
proxy_pass http://127.0.0.1:18800/approve;
|
|
@@ -4,7 +4,12 @@ server {
|
|
|
4
4
|
root /var/www/wip.computer/public_html;
|
|
5
5
|
index index.html index.htm;
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
# Log redaction: F-007 in the VPS hosted-mcp audit.
|
|
8
|
+
# The "redacted" format is defined in /etc/nginx/conf.d/redact-logs.conf
|
|
9
|
+
# (source: src/hosted-mcp/nginx/conf.d/redact-logs.conf). It masks
|
|
10
|
+
# token=, ticket=, api_key=, access_token=, and ck- values in the
|
|
11
|
+
# request line and Referer before the line is written.
|
|
12
|
+
access_log /var/log/nginx/wip.computer.access.log redacted;
|
|
8
13
|
error_log /var/log/nginx/wip.computer.error.log;
|
|
9
14
|
|
|
10
15
|
# Docs redirect to docs.wip.computer
|
|
@@ -45,6 +50,25 @@ server {
|
|
|
45
50
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
46
51
|
}
|
|
47
52
|
|
|
53
|
+
# Next.js internal asset surface for the Kaleidoscope app on :3001.
|
|
54
|
+
# /codex-remote-control/<tid> renders a Next HTML shell that
|
|
55
|
+
# references /_next/static/chunks/*.js and /_next/static/chunks/*.css.
|
|
56
|
+
# Without this proxy, those asset requests fall through to
|
|
57
|
+
# `location /` try_files and return the static homepage HTML, which
|
|
58
|
+
# makes the browser fail to hydrate (page renders blank). Same
|
|
59
|
+
# upstream as /codex-remote-control/. Covers /_next/static/* and
|
|
60
|
+
# /_next/image* paths used by the Next runtime.
|
|
61
|
+
location /_next/ {
|
|
62
|
+
proxy_pass http://127.0.0.1:3001;
|
|
63
|
+
proxy_http_version 1.1;
|
|
64
|
+
proxy_set_header Upgrade $http_upgrade;
|
|
65
|
+
proxy_set_header Connection "upgrade";
|
|
66
|
+
proxy_set_header Host $host;
|
|
67
|
+
proxy_set_header X-Real-IP $remote_addr;
|
|
68
|
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
69
|
+
proxy_set_header X-Forwarded-Proto $scheme;
|
|
70
|
+
}
|
|
71
|
+
|
|
48
72
|
# Health check
|
|
49
73
|
location /health {
|
|
50
74
|
proxy_pass http://127.0.0.1:18800/health;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# audit-logs.sh: F-007 pre-fix log audit for the VPS hosted-mcp surface.
|
|
3
|
+
#
|
|
4
|
+
# Read-only. No log mutation, no rotation, no clearing. Greps nginx and
|
|
5
|
+
# PM2 logs for bearer/ticket/api-key shaped values that may have leaked
|
|
6
|
+
# during the period when the WS upgrade accepted ?token=ck- and the
|
|
7
|
+
# phone app sent ck values in URLs.
|
|
8
|
+
#
|
|
9
|
+
# Output: per-source match count, plus up to N redacted sample lines per
|
|
10
|
+
# source so you can see which path / agent / time range is affected
|
|
11
|
+
# without ever printing the leaked secret in this terminal session.
|
|
12
|
+
#
|
|
13
|
+
# If any source shows a non-zero count, treat the matching ck values as
|
|
14
|
+
# already-leaked. Rotate them as part of the F-002 + F-005a deploy
|
|
15
|
+
# (see ai/product/bugs/security/2026-04-28--cc-mini--vps-hosted-mcp-audit.md).
|
|
16
|
+
#
|
|
17
|
+
# Usage on the VPS:
|
|
18
|
+
# bash audit-logs.sh
|
|
19
|
+
# bash audit-logs.sh --days 14 # widen log window for rotated logs
|
|
20
|
+
# bash audit-logs.sh --samples 20 # show more redacted samples
|
|
21
|
+
# bash audit-logs.sh --json # machine-readable output (counts only)
|
|
22
|
+
#
|
|
23
|
+
# Source: src/hosted-mcp/scripts/audit-logs.sh
|
|
24
|
+
|
|
25
|
+
set -euo pipefail
|
|
26
|
+
|
|
27
|
+
# ── Defaults ────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
DAYS=7
|
|
30
|
+
SAMPLES=10
|
|
31
|
+
JSON=0
|
|
32
|
+
NGINX_LOG_DIR=/var/log/nginx
|
|
33
|
+
PM2_APP=mcp-server
|
|
34
|
+
|
|
35
|
+
# ── Args ────────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
while [ $# -gt 0 ]; do
|
|
38
|
+
case "$1" in
|
|
39
|
+
--days) DAYS=$2; shift 2 ;;
|
|
40
|
+
--samples) SAMPLES=$2; shift 2 ;;
|
|
41
|
+
--json) JSON=1; shift 1 ;;
|
|
42
|
+
--nginx-log-dir) NGINX_LOG_DIR=$2; shift 2 ;;
|
|
43
|
+
--pm2-app) PM2_APP=$2; shift 2 ;;
|
|
44
|
+
-h|--help)
|
|
45
|
+
sed -n '2,30p' "$0"
|
|
46
|
+
exit 0
|
|
47
|
+
;;
|
|
48
|
+
*) echo "Unknown arg: $1" >&2; exit 2 ;;
|
|
49
|
+
esac
|
|
50
|
+
done
|
|
51
|
+
|
|
52
|
+
# ── Patterns ────────────────────────────────────────────────────────
|
|
53
|
+
#
|
|
54
|
+
# Three families of leak we are looking for. Each is a single ERE that
|
|
55
|
+
# `grep -E` will accept. We match conservatively (\b, exact prefixes)
|
|
56
|
+
# so we do not over-flag random bytes that happen to resemble a key.
|
|
57
|
+
|
|
58
|
+
PAT_QUERY_TOKEN='[?&](token|ticket|api_key|access_token)=[^[:space:]&"]+'
|
|
59
|
+
PAT_CK_KEY='\bck-[A-Za-z0-9_-]{6,}\b'
|
|
60
|
+
PAT_AUTH_BEARER='Authorization:[[:space:]]*Bearer[[:space:]]+ck-[A-Za-z0-9_-]{6,}'
|
|
61
|
+
|
|
62
|
+
# ── Redaction (for sample output only) ──────────────────────────────
|
|
63
|
+
#
|
|
64
|
+
# Replace the matched value with a marker before we print the sample
|
|
65
|
+
# line. This way the audit's own output never reproduces the leaked
|
|
66
|
+
# secret in the operator's terminal/scrollback.
|
|
67
|
+
|
|
68
|
+
redact() {
|
|
69
|
+
sed -E \
|
|
70
|
+
-e 's/([?&](token|ticket|api_key|access_token)=)[^&" \t]+/\1[REDACTED]/g' \
|
|
71
|
+
-e 's/\bck-[A-Za-z0-9_-]+/[REDACTED ck-]/g'
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
# ── Source inventory ────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
declare -a SOURCES
|
|
77
|
+
declare -A SOURCE_LABEL
|
|
78
|
+
|
|
79
|
+
add_source() {
|
|
80
|
+
local label=$1
|
|
81
|
+
local path=$2
|
|
82
|
+
if [ -e "$path" ] || compgen -G "$path" > /dev/null; then
|
|
83
|
+
SOURCES+=("$path")
|
|
84
|
+
SOURCE_LABEL["$path"]=$label
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
add_source "nginx access (current)" "${NGINX_LOG_DIR}/access.log"
|
|
89
|
+
add_source "nginx error (current)" "${NGINX_LOG_DIR}/error.log"
|
|
90
|
+
add_source "nginx wip.computer.access" "${NGINX_LOG_DIR}/wip.computer.access.log"
|
|
91
|
+
add_source "nginx wip.computer.error" "${NGINX_LOG_DIR}/wip.computer.error.log"
|
|
92
|
+
|
|
93
|
+
# ── Counters ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
total_matches=0
|
|
96
|
+
declare -A counts
|
|
97
|
+
|
|
98
|
+
scan_one() {
|
|
99
|
+
local path=$1
|
|
100
|
+
local label=$2
|
|
101
|
+
local count
|
|
102
|
+
if [[ "$path" == *.gz ]]; then
|
|
103
|
+
count=$(sudo zgrep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null | wc -l | tr -d ' ')
|
|
104
|
+
else
|
|
105
|
+
count=$(sudo grep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null | wc -l | tr -d ' ')
|
|
106
|
+
fi
|
|
107
|
+
counts["$path"]=$count
|
|
108
|
+
total_matches=$(( total_matches + count ))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
print_samples() {
|
|
112
|
+
local path=$1
|
|
113
|
+
local label=$2
|
|
114
|
+
local count=${counts["$path"]}
|
|
115
|
+
[ "$count" -eq 0 ] && return
|
|
116
|
+
echo
|
|
117
|
+
echo "── $label ($path)"
|
|
118
|
+
echo " matches: $count"
|
|
119
|
+
echo " sample (redacted, up to $SAMPLES lines):"
|
|
120
|
+
if [[ "$path" == *.gz ]]; then
|
|
121
|
+
sudo zgrep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null \
|
|
122
|
+
| head -n "$SAMPLES" | redact | sed 's/^/ /'
|
|
123
|
+
else
|
|
124
|
+
sudo grep -E "$PAT_QUERY_TOKEN|$PAT_CK_KEY|$PAT_AUTH_BEARER" "$path" 2>/dev/null \
|
|
125
|
+
| head -n "$SAMPLES" | redact | sed 's/^/ /'
|
|
126
|
+
fi
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
# ── Run nginx scan ──────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
if [ "$JSON" -ne 1 ]; then
|
|
132
|
+
echo "F-007 log audit"
|
|
133
|
+
echo "Days back (rotated): $DAYS"
|
|
134
|
+
echo "Samples per source: $SAMPLES"
|
|
135
|
+
echo "Nginx log dir: $NGINX_LOG_DIR"
|
|
136
|
+
echo
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
# Current logs
|
|
140
|
+
for path in "${SOURCES[@]}"; do
|
|
141
|
+
scan_one "$path" "${SOURCE_LABEL[$path]}"
|
|
142
|
+
done
|
|
143
|
+
|
|
144
|
+
# Rotated logs within the window
|
|
145
|
+
shopt -s nullglob
|
|
146
|
+
declare -a ROTATED
|
|
147
|
+
for f in "${NGINX_LOG_DIR}"/*.gz "${NGINX_LOG_DIR}"/*.[0-9]*; do
|
|
148
|
+
if [ -f "$f" ]; then
|
|
149
|
+
if find "$f" -mtime "-${DAYS}" -print -quit | grep -q .; then
|
|
150
|
+
ROTATED+=("$f")
|
|
151
|
+
fi
|
|
152
|
+
fi
|
|
153
|
+
done
|
|
154
|
+
|
|
155
|
+
for path in "${ROTATED[@]}"; do
|
|
156
|
+
scan_one "$path" "rotated $(basename "$path")"
|
|
157
|
+
done
|
|
158
|
+
|
|
159
|
+
# ── PM2 ─────────────────────────────────────────────────────────────
|
|
160
|
+
#
|
|
161
|
+
# pm2 logs --nostream prints recent lines from the in-memory buffer.
|
|
162
|
+
# This is best-effort: it covers the current PM2 process lifetime, not
|
|
163
|
+
# rotated copies. PM2's persisted logs (if any) live under
|
|
164
|
+
# ~/.pm2/logs/<app>-out.log and ~/.pm2/logs/<app>-error.log.
|
|
165
|
+
|
|
166
|
+
PM2_OUT="$HOME/.pm2/logs/${PM2_APP}-out.log"
|
|
167
|
+
PM2_ERR="$HOME/.pm2/logs/${PM2_APP}-error.log"
|
|
168
|
+
|
|
169
|
+
if [ -e "$PM2_OUT" ]; then add_source "pm2 ${PM2_APP} out" "$PM2_OUT"; scan_one "$PM2_OUT" "pm2 out"; fi
|
|
170
|
+
if [ -e "$PM2_ERR" ]; then add_source "pm2 ${PM2_APP} error" "$PM2_ERR"; scan_one "$PM2_ERR" "pm2 error"; fi
|
|
171
|
+
|
|
172
|
+
# ── Output ──────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
if [ "$JSON" -eq 1 ]; then
|
|
175
|
+
printf '{"total_matches": %d, "by_source": {' "$total_matches"
|
|
176
|
+
first=1
|
|
177
|
+
for path in "${!counts[@]}"; do
|
|
178
|
+
[ "$first" -eq 1 ] && first=0 || printf ','
|
|
179
|
+
printf '"%s": %d' "$path" "${counts[$path]}"
|
|
180
|
+
done
|
|
181
|
+
printf '}}\n'
|
|
182
|
+
else
|
|
183
|
+
echo "── Summary"
|
|
184
|
+
for path in "${!counts[@]}"; do
|
|
185
|
+
label=${SOURCE_LABEL[$path]:-${path}}
|
|
186
|
+
printf " %-40s %s matches\n" "$label" "${counts[$path]}"
|
|
187
|
+
done
|
|
188
|
+
echo
|
|
189
|
+
echo "── Total matches: $total_matches"
|
|
190
|
+
|
|
191
|
+
for path in "${!counts[@]}"; do
|
|
192
|
+
print_samples "$path" "${SOURCE_LABEL[$path]:-${path}}"
|
|
193
|
+
done
|
|
194
|
+
|
|
195
|
+
echo
|
|
196
|
+
if [ "$total_matches" -gt 0 ]; then
|
|
197
|
+
echo "ACTION REQUIRED:"
|
|
198
|
+
echo " Treat any matched ck values as leaked. Rotate them as part of the"
|
|
199
|
+
echo " F-002 + F-005a deploy. After deploying redact-logs.conf, re-run"
|
|
200
|
+
echo " this script and confirm zero matches in the post-rotation window."
|
|
201
|
+
else
|
|
202
|
+
echo "OK: no leak signatures found in the scanned window."
|
|
203
|
+
echo " Re-run after every deploy that adds or moves auth surfaces."
|
|
204
|
+
fi
|
|
205
|
+
fi
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# verify-deploy.sh: Verify a hosted-mcp deploy manifest against the live
|
|
3
|
+
# VPS. Reads a manifest produced by deploy.sh, fetches the current
|
|
4
|
+
# sha256 of each declared destination on the remote, and compares.
|
|
5
|
+
#
|
|
6
|
+
# Per F-009 of the VPS hosted-mcp audit. Use after deploy.sh, after
|
|
7
|
+
# any apparent change to the live tree, or as part of the pre-dogfood
|
|
8
|
+
# smoke test.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# bash verify-deploy.sh wip.computer:/var/www/.../<timestamp>.json
|
|
12
|
+
# bash verify-deploy.sh /path/to/local/manifest.json --remote wip.computer
|
|
13
|
+
# bash verify-deploy.sh latest # use latest manifest on VPS
|
|
14
|
+
#
|
|
15
|
+
# Exits non-zero on any mismatch. Output lists each file as OK or DIFF.
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
REMOTE="wip.computer"
|
|
20
|
+
MANIFEST_DIR="/var/www/wip.computer/deploy-manifests/hosted-mcp"
|
|
21
|
+
ARG=""
|
|
22
|
+
|
|
23
|
+
while [ $# -gt 0 ]; do
|
|
24
|
+
case "$1" in
|
|
25
|
+
--remote) REMOTE=$2; shift 2 ;;
|
|
26
|
+
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
|
|
27
|
+
*) ARG=$1; shift ;;
|
|
28
|
+
esac
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
if [ -z "$ARG" ]; then
|
|
32
|
+
echo "usage: bash verify-deploy.sh <manifest-path-or-latest>" >&2
|
|
33
|
+
exit 2
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# Resolve manifest path.
|
|
37
|
+
if [ "$ARG" = "latest" ]; then
|
|
38
|
+
MANIFEST_PATH=$(ssh "${REMOTE}" "ls -t ${MANIFEST_DIR}/*.json 2>/dev/null | head -1")
|
|
39
|
+
if [ -z "$MANIFEST_PATH" ]; then
|
|
40
|
+
echo "FATAL: no manifests found in ${REMOTE}:${MANIFEST_DIR}" >&2
|
|
41
|
+
exit 1
|
|
42
|
+
fi
|
|
43
|
+
echo "Latest manifest: ${MANIFEST_PATH}"
|
|
44
|
+
MANIFEST_JSON=$(ssh "${REMOTE}" "cat ${MANIFEST_PATH}")
|
|
45
|
+
elif [[ "$ARG" == *":"* ]]; then
|
|
46
|
+
# remote:path form
|
|
47
|
+
MANIFEST_REMOTE=${ARG%%:*}
|
|
48
|
+
MANIFEST_PATH=${ARG#*:}
|
|
49
|
+
MANIFEST_JSON=$(ssh "${MANIFEST_REMOTE}" "cat ${MANIFEST_PATH}")
|
|
50
|
+
elif [ -f "$ARG" ]; then
|
|
51
|
+
MANIFEST_PATH=$ARG
|
|
52
|
+
MANIFEST_JSON=$(cat "$ARG")
|
|
53
|
+
else
|
|
54
|
+
echo "FATAL: manifest not found: ${ARG}" >&2
|
|
55
|
+
exit 1
|
|
56
|
+
fi
|
|
57
|
+
|
|
58
|
+
# Parse files[] entries via python3.
|
|
59
|
+
if ! command -v python3 >/dev/null 2>&1; then
|
|
60
|
+
echo "FATAL: python3 required" >&2
|
|
61
|
+
exit 1
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
ENTRIES=$(printf '%s' "$MANIFEST_JSON" | python3 -c '
|
|
65
|
+
import json,sys
|
|
66
|
+
m=json.load(sys.stdin)
|
|
67
|
+
for f in m.get("files",[]):
|
|
68
|
+
print(f.get("source","")+"\t"+f.get("destination","")+"\t"+f.get("sha256",""))
|
|
69
|
+
')
|
|
70
|
+
|
|
71
|
+
if [ -z "$ENTRIES" ]; then
|
|
72
|
+
echo "FATAL: no files entries in manifest" >&2
|
|
73
|
+
exit 1
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
echo "Manifest: ${MANIFEST_PATH}"
|
|
77
|
+
echo "Verifying ${REMOTE}:"
|
|
78
|
+
echo
|
|
79
|
+
|
|
80
|
+
OK=0
|
|
81
|
+
FAIL=0
|
|
82
|
+
while IFS=$'\t' read -r src dst expected; do
|
|
83
|
+
[ -z "$dst" ] && continue
|
|
84
|
+
# ssh -n redirects stdin from /dev/null so ssh does not consume the
|
|
85
|
+
# loop's heredoc and short-circuit after the first iteration.
|
|
86
|
+
remote_sha=$(ssh -n "${REMOTE}" "sudo sha256sum ${dst} 2>/dev/null" | awk '{print $1}' || echo "")
|
|
87
|
+
if [ -z "$remote_sha" ]; then
|
|
88
|
+
printf " MISSING %s\n" "$dst"
|
|
89
|
+
FAIL=$((FAIL+1))
|
|
90
|
+
elif [ "$remote_sha" = "$expected" ]; then
|
|
91
|
+
printf " OK %s\n" "$dst"
|
|
92
|
+
OK=$((OK+1))
|
|
93
|
+
else
|
|
94
|
+
printf " DIFF %s\n expected %s\n live %s\n" "$dst" "$expected" "$remote_sha"
|
|
95
|
+
FAIL=$((FAIL+1))
|
|
96
|
+
fi
|
|
97
|
+
done <<< "$ENTRIES"
|
|
98
|
+
|
|
99
|
+
echo
|
|
100
|
+
echo "Summary: ${OK} ok, ${FAIL} mismatched"
|
|
101
|
+
[ "$FAIL" -eq 0 ] && exit 0
|
|
102
|
+
exit 1
|