@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 +89 -1
- package/bin/presenters/index.js +208 -0
- package/bin/presenters/json.js +66 -0
- package/bin/presenters/text.js +407 -0
- package/bin/warp-graph.js +206 -534
- package/index.d.ts +24 -0
- package/package.json +2 -2
- package/src/domain/WarpGraph.js +72 -15
- package/src/domain/services/HttpSyncServer.js +74 -6
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +9 -56
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/visualization/renderers/ascii/seek.js +172 -22
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
|
-
##
|
|
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
|
+
}
|