@git-stunts/git-warp 10.4.2 → 10.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/SECURITY.md CHANGED
@@ -25,6 +25,94 @@ The `GitGraphAdapter` validates all ref arguments to prevent injection attacks:
25
25
  - **Bitmap Indexing**: Sharded Roaring Bitmap indexes enable O(1) lookups without loading entire graphs
26
26
  - **Delimiter Safety**: Uses ASCII Record Separator (`\x1E`) to prevent message collision
27
27
 
28
- ## 🐞 Reporting a Vulnerability
28
+ ## Sync Authentication (SHIELD)
29
+
30
+ ### Overview
31
+
32
+ The HTTP sync protocol supports optional HMAC-SHA256 request signing with replay protection. When enabled, every sync request must carry a valid signature computed over a canonical payload that includes the request body, timestamp, and a unique nonce.
33
+
34
+ ### Threat Model
35
+
36
+ **Protected against:**
37
+ - Unauthorized sync requests from unknown peers
38
+ - Replay attacks (nonce-based, with 5-minute TTL window)
39
+ - Request body tampering (HMAC covers body SHA-256)
40
+ - Timing attacks on signature comparison (`timingSafeEqual`)
41
+
42
+ **Not protected against:**
43
+ - Compromised shared secrets (rotate keys immediately if leaked)
44
+ - Denial-of-service (body size limits provide basic protection, but no rate limiting)
45
+ - Man-in-the-middle without TLS (use HTTPS in production)
46
+
47
+ ### Authentication Flow
48
+
49
+ 1. Client computes SHA-256 of request body
50
+ 2. Client builds canonical payload: `warp-v1|KEY_ID|METHOD|PATH|TIMESTAMP|NONCE|CONTENT_TYPE|BODY_SHA256`
51
+ 3. Client computes HMAC-SHA256 of canonical payload using shared secret
52
+ 4. Client sends 5 auth headers: `x-warp-sig-version`, `x-warp-key-id`, `x-warp-timestamp`, `x-warp-nonce`, `x-warp-signature`
53
+ 5. Server validates header formats (cheap checks first)
54
+ 6. Server checks clock skew (default: 5 minutes)
55
+ 7. Server reserves nonce atomically (prevents replay)
56
+ 8. Server resolves key by key-id
57
+ 9. Server recomputes HMAC and compares with constant-time equality
58
+
59
+ ### Enforcement Modes
60
+
61
+ - **`enforce`** (default): Rejects requests that fail authentication with appropriate HTTP status codes (400/401/403). No request details leak in error responses.
62
+ - **`log-only`**: Logs authentication failures but allows requests through. Use during rollout to identify issues before enforcing.
63
+
64
+ ### Error Response Hygiene
65
+
66
+ External error responses use coarse status codes and generic reason strings:
67
+ - `400` — Malformed headers (version, timestamp, nonce, signature format)
68
+ - `401` — Missing auth headers, unknown key-id, invalid signature
69
+ - `403` — Expired timestamp, replayed nonce
70
+
71
+ Detailed diagnostics (exact failure reason, key-id, peer info) are sent to the structured logger only.
72
+
73
+ ### Nonce Cache and Restart Semantics
74
+
75
+ The nonce cache is an in-memory LRU (default capacity: 100,000 entries). On server restart, the cache is empty. This means:
76
+ - Nonces from before the restart can be replayed within the 5-minute clock skew window
77
+ - This is an accepted trade-off for simplicity; persistent nonce storage is not implemented in v1
78
+ - For higher security, keep the clock skew window small and use TLS
79
+
80
+ ### Key Rotation
81
+
82
+ Key management uses a key-id system for zero-downtime rotation:
83
+
84
+ 1. Add the new key-id and secret to the server's `keys` map
85
+ 2. Deploy the server
86
+ 3. Update clients to use the new key-id
87
+ 4. Remove the old key-id from the server's `keys` map
88
+ 5. Deploy again
89
+
90
+ Multiple key-ids can coexist indefinitely.
91
+
92
+ ### Configuration
93
+
94
+ **Server (`serve()`):**
95
+ ```js
96
+ await graph.serve({
97
+ port: 3000,
98
+ httpPort: new NodeHttpAdapter(),
99
+ auth: {
100
+ keys: { default: 'your-shared-secret' },
101
+ mode: 'enforce', // or 'log-only'
102
+ },
103
+ });
104
+ ```
105
+
106
+ **Client (`syncWith()`):**
107
+ ```js
108
+ await graph.syncWith('http://peer:3000', {
109
+ auth: {
110
+ secret: 'your-shared-secret',
111
+ keyId: 'default',
112
+ },
113
+ });
114
+ ```
115
+
116
+ ## Reporting a Vulnerability
29
117
 
30
118
  If you discover a security vulnerability, please send an e-mail to [james@flyingrobots.dev](mailto:james@flyingrobots.dev).
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unified output dispatcher for CLI commands.
3
+ *
4
+ * Replaces the 112-line emit() function in warp-graph.js with clean
5
+ * format dispatch: text, json, ndjson — plus view mode handling.
6
+ */
7
+
8
+ import fs from 'node:fs';
9
+ import process from 'node:process';
10
+
11
+ import { stripAnsi } from '../../src/visualization/utils/ansi.js';
12
+ import { renderInfoView } from '../../src/visualization/renderers/ascii/info.js';
13
+ import { renderCheckView } from '../../src/visualization/renderers/ascii/check.js';
14
+ import { renderHistoryView } from '../../src/visualization/renderers/ascii/history.js';
15
+ import { renderPathView } from '../../src/visualization/renderers/ascii/path.js';
16
+ import { renderMaterializeView } from '../../src/visualization/renderers/ascii/materialize.js';
17
+ import { renderSeekView } from '../../src/visualization/renderers/ascii/seek.js';
18
+
19
+ import { stableStringify, compactStringify, sanitizePayload } from './json.js';
20
+ import {
21
+ renderInfo,
22
+ renderQuery,
23
+ renderPath,
24
+ renderCheck,
25
+ renderHistory,
26
+ renderError,
27
+ renderMaterialize,
28
+ renderInstallHooks,
29
+ renderSeek,
30
+ } from './text.js';
31
+
32
+ // ── Color control ────────────────────────────────────────────────────────────
33
+
34
+ /**
35
+ * Determines whether ANSI color codes should be stripped from output.
36
+ *
37
+ * Precedence: FORCE_COLOR=0 (strip) > FORCE_COLOR!='' (keep) > NO_COLOR > !isTTY > CI.
38
+ * @returns {boolean}
39
+ */
40
+ export function shouldStripColor() {
41
+ if (process.env.FORCE_COLOR === '0') {
42
+ return true;
43
+ }
44
+ if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '') {
45
+ return false;
46
+ }
47
+ if (process.env.NO_COLOR !== undefined) {
48
+ return true;
49
+ }
50
+ if (!process.stdout.isTTY) {
51
+ return true;
52
+ }
53
+ if (process.env.CI !== undefined) {
54
+ return true;
55
+ }
56
+ return false;
57
+ }
58
+
59
+ // ── Text renderer map ────────────────────────────────────────────────────────
60
+
61
+ /** @type {Map<string, function(*): string>} */
62
+ const TEXT_RENDERERS = new Map(/** @type {[string, function(*): string][]} */ ([
63
+ ['info', renderInfo],
64
+ ['query', renderQuery],
65
+ ['path', renderPath],
66
+ ['check', renderCheck],
67
+ ['history', renderHistory],
68
+ ['materialize', renderMaterialize],
69
+ ['seek', renderSeek],
70
+ ['install-hooks', renderInstallHooks],
71
+ ]));
72
+
73
+ /** @type {Map<string, function(*): string>} */
74
+ const VIEW_RENDERERS = new Map(/** @type {[string, function(*): string][]} */ ([
75
+ ['info', renderInfoView],
76
+ ['check', renderCheckView],
77
+ ['history', renderHistoryView],
78
+ ['path', renderPathView],
79
+ ['materialize', renderMaterializeView],
80
+ ['seek', renderSeekView],
81
+ ]));
82
+
83
+ // ── HTML export ──────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Wraps SVG content in a minimal HTML document and writes it to disk.
87
+ * @param {string} filePath
88
+ * @param {string} svgContent
89
+ */
90
+ function writeHtmlExport(filePath, svgContent) {
91
+ const html = `<!DOCTYPE html>\n<html><head><meta charset="utf-8"><title>git-warp</title></head><body>\n${svgContent}\n</body></html>`;
92
+ fs.writeFileSync(filePath, html);
93
+ }
94
+
95
+ // ── SVG / HTML file export ───────────────────────────────────────────────────
96
+
97
+ /**
98
+ * Handles svg:PATH and html:PATH view modes for commands that carry _renderedSvg.
99
+ * @param {*} payload
100
+ * @param {string} view
101
+ * @returns {boolean} true if handled
102
+ */
103
+ function handleFileExport(payload, view) {
104
+ if (typeof view === 'string' && view.startsWith('svg:')) {
105
+ const svgPath = view.slice(4);
106
+ if (!payload._renderedSvg) {
107
+ process.stderr.write('No graph data — skipping SVG export.\n');
108
+ } else {
109
+ fs.writeFileSync(svgPath, payload._renderedSvg);
110
+ process.stderr.write(`SVG written to ${svgPath}\n`);
111
+ }
112
+ return true;
113
+ }
114
+ if (typeof view === 'string' && view.startsWith('html:')) {
115
+ const htmlPath = view.slice(5);
116
+ if (!payload._renderedSvg) {
117
+ process.stderr.write('No graph data — skipping HTML export.\n');
118
+ } else {
119
+ writeHtmlExport(htmlPath, payload._renderedSvg);
120
+ process.stderr.write(`HTML written to ${htmlPath}\n`);
121
+ }
122
+ return true;
123
+ }
124
+ return false;
125
+ }
126
+
127
+ // ── Output helpers ───────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Writes text to stdout, optionally stripping ANSI codes.
131
+ * @param {string} text
132
+ * @param {boolean} strip
133
+ */
134
+ function writeText(text, strip) {
135
+ process.stdout.write(strip ? stripAnsi(text) : text);
136
+ }
137
+
138
+ // ── Main dispatcher ──────────────────────────────────────────────────────────
139
+
140
+ /**
141
+ * Writes a command result to stdout/stderr in the requested format.
142
+ *
143
+ * @param {*} payload - Command result payload
144
+ * @param {{format: string, command: string, view: string|null|boolean}} options
145
+ */
146
+ export function present(payload, { format, command, view }) {
147
+ // Error payloads always go to stderr as plain text
148
+ if (payload?.error) {
149
+ process.stderr.write(renderError(payload));
150
+ return;
151
+ }
152
+
153
+ // JSON: sanitize + pretty-print
154
+ if (format === 'json') {
155
+ process.stdout.write(`${stableStringify(sanitizePayload(payload))}\n`);
156
+ return;
157
+ }
158
+
159
+ // NDJSON: sanitize + compact single line
160
+ if (format === 'ndjson') {
161
+ process.stdout.write(`${compactStringify(sanitizePayload(payload))}\n`);
162
+ return;
163
+ }
164
+
165
+ // Text with view mode
166
+ if (view) {
167
+ presentView(payload, command, view);
168
+ return;
169
+ }
170
+
171
+ // Plain text
172
+ const renderer = TEXT_RENDERERS.get(command);
173
+ if (renderer) {
174
+ writeText(renderer(payload), shouldStripColor());
175
+ } else {
176
+ // Fallback for unknown commands
177
+ process.stdout.write(`${stableStringify(sanitizePayload(payload))}\n`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Handles --view output dispatch (ASCII view, SVG file, HTML file).
183
+ * @param {*} payload
184
+ * @param {string} command
185
+ * @param {string|boolean} view
186
+ */
187
+ function presentView(payload, command, view) {
188
+ const strip = shouldStripColor();
189
+
190
+ // File exports: svg:PATH, html:PATH
191
+ if (handleFileExport(payload, /** @type {string} */ (view))) {
192
+ return;
193
+ }
194
+
195
+ // query is special: uses pre-rendered _renderedAscii
196
+ if (command === 'query') {
197
+ writeText(`${payload._renderedAscii ?? ''}\n`, strip);
198
+ return;
199
+ }
200
+
201
+ // Dispatch to view renderer
202
+ const viewRenderer = VIEW_RENDERERS.get(command);
203
+ if (viewRenderer) {
204
+ writeText(viewRenderer(payload), strip);
205
+ } else {
206
+ writeText(`${stableStringify(sanitizePayload(payload))}\n`, strip);
207
+ }
208
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * JSON / NDJSON serialization utilities for CLI output.
3
+ *
4
+ * - stableStringify: pretty-printed, sorted-key JSON (--json)
5
+ * - compactStringify: single-line, sorted-key JSON (--ndjson)
6
+ * - sanitizePayload: strips internal _-prefixed keys before serialization
7
+ */
8
+
9
+ /**
10
+ * Recursively sorts object keys for deterministic JSON output.
11
+ * @param {*} input
12
+ * @returns {*}
13
+ */
14
+ function normalize(input) {
15
+ if (Array.isArray(input)) {
16
+ return input.map(normalize);
17
+ }
18
+ if (input && typeof input === 'object') {
19
+ /** @type {Record<string, *>} */
20
+ const sorted = {};
21
+ for (const key of Object.keys(input).sort()) {
22
+ sorted[key] = normalize(input[key]);
23
+ }
24
+ return sorted;
25
+ }
26
+ return input;
27
+ }
28
+
29
+ /**
30
+ * Pretty-printed JSON with sorted keys (2-space indent).
31
+ * @param {*} value
32
+ * @returns {string}
33
+ */
34
+ export function stableStringify(value) {
35
+ return JSON.stringify(normalize(value), null, 2);
36
+ }
37
+
38
+ /**
39
+ * Single-line JSON with sorted keys (no indent).
40
+ * @param {*} value
41
+ * @returns {string}
42
+ */
43
+ export function compactStringify(value) {
44
+ return JSON.stringify(normalize(value));
45
+ }
46
+
47
+ /**
48
+ * Shallow-clones a payload, removing all top-level underscore-prefixed keys.
49
+ * These are internal rendering artifacts (e.g. _renderedSvg, _renderedAscii)
50
+ * that should not leak into JSON/NDJSON output.
51
+ * @param {*} payload
52
+ * @returns {*}
53
+ */
54
+ export function sanitizePayload(payload) {
55
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
56
+ return payload;
57
+ }
58
+ /** @type {Record<string, *>} */
59
+ const clean = {};
60
+ for (const key of Object.keys(payload)) {
61
+ if (!key.startsWith('_')) {
62
+ clean[key] = payload[key];
63
+ }
64
+ }
65
+ return clean;
66
+ }