@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.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/CHANGELOG.md +139 -0
- package/bin/agentic-security.js +3508 -69
- package/dist/1122.index.js +702 -0
- package/dist/{301.index.js → 1301.index.js} +2 -2
- package/dist/1379.index.js +591 -0
- package/dist/{444.index.js → 1444.index.js} +2 -2
- package/dist/{660.index.js → 1660.index.js} +2 -2
- package/dist/{700.index.js → 1700.index.js} +2 -2
- package/dist/{905.index.js → 1905.index.js} +3 -3
- package/dist/{920.index.js → 1920.index.js} +3 -3
- package/dist/{238.index.js → 2238.index.js} +3 -3
- package/dist/{271.index.js → 2271.index.js} +2 -2
- package/dist/{985.index.js → 2376.index.js} +1260 -340
- package/dist/2432.index.js +793 -0
- package/dist/2659.index.js +93 -0
- package/dist/{826.index.js → 2826.index.js} +2 -2
- package/dist/{830.index.js → 2830.index.js} +2 -2
- package/dist/2923.index.js +298 -0
- package/dist/{1.index.js → 3001.index.js} +5 -5
- package/dist/{117.index.js → 3117.index.js} +3 -3
- package/dist/3180.index.js +307 -0
- package/dist/3276.index.js +117 -0
- package/dist/{415.index.js → 3415.index.js} +2 -2
- package/dist/{499.index.js → 3499.index.js} +2 -2
- package/dist/3518.index.js +450 -0
- package/dist/{526.index.js → 3526.index.js} +5 -5
- package/dist/{736.index.js → 3736.index.js} +4 -4
- package/dist/{839.index.js → 3839.index.js} +4 -4
- package/dist/{113.index.js → 4113.index.js} +5 -5
- package/dist/{265.index.js → 4265.index.js} +2 -2
- package/dist/{384.index.js → 4384.index.js} +3 -3
- package/dist/4547.index.js +268 -0
- package/dist/4863.index.js +422 -0
- package/dist/{970.index.js → 4970.index.js} +2 -2
- package/dist/5051.index.js +770 -0
- package/dist/{144.index.js → 5144.index.js} +5 -5
- package/dist/{333.index.js → 5333.index.js} +3 -3
- package/dist/5343.index.js +185 -0
- package/dist/5350.index.js +866 -0
- package/dist/5561.index.js +436 -0
- package/dist/{637.index.js → 5637.index.js} +3 -3
- package/dist/{449.index.js → 5830.index.js} +2 -2
- package/dist/6626.index.js +532 -0
- package/dist/6662.index.js +297 -0
- package/dist/{675.index.js → 6675.index.js} +5 -5
- package/dist/{730.index.js → 6730.index.js} +6 -6
- package/dist/6829.index.js +225 -0
- package/dist/6944.index.js +130 -0
- package/dist/{178.index.js → 7178.index.js} +3 -3
- package/dist/{227.index.js → 7227.index.js} +2 -2
- package/dist/7310.index.js +520 -0
- package/dist/{552.index.js → 7552.index.js} +4 -4
- package/dist/7709.index.js +78 -0
- package/dist/8218.index.js +160 -0
- package/dist/{476.index.js → 8476.index.js} +4 -4
- package/dist/{513.index.js → 8513.index.js} +5 -5
- package/dist/{520.index.js → 8520.index.js} +2 -2
- package/dist/{718.index.js → 8718.index.js} +2 -2
- package/dist/{752.index.js → 8752.index.js} +2 -2
- package/dist/8846.index.js +100 -0
- package/dist/{435.index.js → 9091.index.js} +560 -189
- package/dist/{207.index.js → 9207.index.js} +2 -2
- package/dist/{220.index.js → 9220.index.js} +2 -2
- package/dist/9390.index.js +163 -0
- package/dist/{503.index.js → 9503.index.js} +2 -2
- package/dist/{801.index.js → 9801.index.js} +2 -2
- package/dist/{824.index.js → 9824.index.js} +2 -2
- package/dist/agentic-security.mjs +16 -16
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/package.json +12 -5
- package/src/dataflow/CLAUDE.md +1 -1
- package/src/dataflow/catalog.js +42 -0
- package/src/dataflow/orm-write-catalog.js +175 -0
- package/src/engine.js +92 -1
- package/src/ir/CLAUDE.md +1 -0
- package/src/ir/chrome-probe.mjs +150 -0
- package/src/ir/parser-js.js +94 -7
- package/src/lineage/CLAUDE.md +1203 -0
- package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
- package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
- package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
- package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
- package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
- package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
- package/src/lineage/DESIGN_REGISTRIES.md +880 -0
- package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
- package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
- package/src/lineage/classification.js +56 -0
- package/src/lineage/coverage.js +658 -0
- package/src/lineage/cross-repo-link.js +107 -0
- package/src/lineage/dataflow-graph.schema.json +184 -0
- package/src/lineage/decision-story.js +206 -0
- package/src/lineage/drift-policy.js +279 -0
- package/src/lineage/driver.js +135 -0
- package/src/lineage/engine.js +992 -0
- package/src/lineage/export-briefing.js +628 -0
- package/src/lineage/export-csv.js +62 -0
- package/src/lineage/export-json.js +238 -0
- package/src/lineage/export-privacy.js +258 -0
- package/src/lineage/federation-loader.js +111 -0
- package/src/lineage/field-identity.js +78 -0
- package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
- package/src/lineage/fixtures/flagship-graph.json +1453 -0
- package/src/lineage/flow-grade.js +221 -0
- package/src/lineage/governance-edit.js +169 -0
- package/src/lineage/graph-builder.js +1114 -0
- package/src/lineage/graph-diff.js +431 -0
- package/src/lineage/graph-snapshot.js +180 -0
- package/src/lineage/handling-analyzer.js +168 -0
- package/src/lineage/ids.js +349 -0
- package/src/lineage/impact-assessment.js +76 -0
- package/src/lineage/impact-engine.js +268 -0
- package/src/lineage/index.js +281 -0
- package/src/lineage/language-coverage-tiers.js +58 -0
- package/src/lineage/obligation-mapping.js +126 -0
- package/src/lineage/obligation-predicates.js +235 -0
- package/src/lineage/observation-adapters.js +282 -0
- package/src/lineage/observation-correlation.js +622 -0
- package/src/lineage/observation-store.js +497 -0
- package/src/lineage/path-query.js +410 -0
- package/src/lineage/path-store.js +400 -0
- package/src/lineage/protection.js +53 -0
- package/src/lineage/recipient-profile.js +192 -0
- package/src/lineage/recipient-registry.js +394 -0
- package/src/lineage/redact-graph.js +224 -0
- package/src/lineage/remediation.js +417 -0
- package/src/lineage/resolve-destination.js +91 -0
- package/src/lineage/runtime-observation.js +464 -0
- package/src/lineage/scenario-diff.js +84 -0
- package/src/lineage/scenario-engine.js +251 -0
- package/src/lineage/scenario.js +101 -0
- package/src/lineage/schema.js +167 -0
- package/src/lineage/sink-registry.js +427 -0
- package/src/lineage/source-registry.js +357 -0
- package/src/lineage/source-seeding.js +212 -0
- package/src/lineage/summaries.js +590 -0
- package/src/lineage/transform-catalog.js +397 -0
- package/src/lineage/transit-protection.js +150 -0
- package/src/lineage/validate.js +285 -0
- package/src/mcp/CLAUDE.md +7 -1
- package/src/mcp/dataflow-tools.js +160 -0
- package/src/mcp/server.js +1 -1
- package/src/mcp/tools.js +2 -1
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/CLAUDE.md +19 -0
- package/src/posture/artifact-registry.js +52 -0
- package/src/posture/auditor-walkthrough.js +76 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
- package/src/posture/obligation-evidence-pack.js +202 -0
- package/src/posture/remediation-ledger.js +337 -0
- package/src/server/CLAUDE.md +47 -0
- package/src/server/graph-loader.js +141 -0
- package/src/server/http-server.js +325 -0
- package/src/server/routes.js +129 -0
- package/src/server/security.js +111 -0
- package/src/server/static-assets.js +139 -0
- package/dist/11.index.js +0 -353
- package/dist/259.index.js +0 -975
- package/dist/317.index.js +0 -300
- package/dist/609.index.js +0 -741
- package/dist/838.index.js +0 -152
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
export const id = 5051;
|
|
2
|
+
export const ids = [5051,6944];
|
|
3
|
+
export const modules = {
|
|
4
|
+
|
|
5
|
+
/***/ 5051:
|
|
6
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
// EXPORTS
|
|
10
|
+
__webpack_require__.d(__webpack_exports__, {
|
|
11
|
+
createExploreServer: () => (/* binding */ createExploreServer)
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// UNUSED EXPORTS: DEFAULT_IDLE_TIMEOUT_MS, MAX_REQUEST_BODY_BYTES, TOKEN_HEADER
|
|
15
|
+
|
|
16
|
+
// EXTERNAL MODULE: external "node:http"
|
|
17
|
+
var external_node_http_ = __webpack_require__(7067);
|
|
18
|
+
// EXTERNAL MODULE: external "node:fs"
|
|
19
|
+
var external_node_fs_ = __webpack_require__(3024);
|
|
20
|
+
// EXTERNAL MODULE: external "node:path"
|
|
21
|
+
var external_node_path_ = __webpack_require__(6760);
|
|
22
|
+
// EXTERNAL MODULE: ./src/server/security.js
|
|
23
|
+
var security = __webpack_require__(6944);
|
|
24
|
+
// EXTERNAL MODULE: ./src/server/routes.js
|
|
25
|
+
var routes = __webpack_require__(4268);
|
|
26
|
+
// EXTERNAL MODULE: external "node:url"
|
|
27
|
+
var external_node_url_ = __webpack_require__(3136);
|
|
28
|
+
;// CONCATENATED MODULE: ./src/server/static-assets.js
|
|
29
|
+
// static-assets.js — Milestone 3, sub-project Wire.
|
|
30
|
+
//
|
|
31
|
+
// Confines the `explore` server's new same-origin static-asset surface to an
|
|
32
|
+
// explicit ALLOWLIST of `frontend/`'s own real, servable files. This is the
|
|
33
|
+
// first place scanner/src/server/ ever reads arbitrary requested paths off
|
|
34
|
+
// disk — S1's five JSON endpoints never touched the filesystem beyond the
|
|
35
|
+
// one already-loaded, already-verified graph — so path confinement here is
|
|
36
|
+
// genuinely load-bearing, not decorative (see the M3-Wire scoping doc's
|
|
37
|
+
// Decision 2, and the threat-model doc's T4 entry).
|
|
38
|
+
//
|
|
39
|
+
// `resolveStaticAsset()` is a PURE function: no fs/http access anywhere in
|
|
40
|
+
// this file, fully unit-testable in isolation. http-server.js is the only
|
|
41
|
+
// caller that actually reads a resolved file off disk.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
// Located the SAME way scanner/src/mcp/server.js locates files relative to
|
|
47
|
+
// its own module (path.dirname(fileURLToPath(import.meta.url))) — the real,
|
|
48
|
+
// existing precedent for this pattern in this codebase, not a new one.
|
|
49
|
+
// scanner/src/server/ -> ../../../frontend, computed (not guessed) and
|
|
50
|
+
// confirmed to resolve to the real frontend/ directory.
|
|
51
|
+
const _here = external_node_path_.dirname((0,external_node_url_.fileURLToPath)(import.meta.url));
|
|
52
|
+
const FRONTEND_ROOT = external_node_path_.resolve(_here, '..', '..', '..', 'frontend');
|
|
53
|
+
|
|
54
|
+
const CONTENT_TYPE_MAP = Object.freeze({
|
|
55
|
+
'.html': 'text/html; charset=utf-8',
|
|
56
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
57
|
+
'.css': 'text/css; charset=utf-8',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Same-origin-permitting CSP for the static/HTML surface — deliberately
|
|
61
|
+
// DIFFERENT from security.js's CSP_HEADER_VALUE ("default-src 'none'"),
|
|
62
|
+
// which stays correct for the JSON-only /api/v1/* routes (unchanged). This
|
|
63
|
+
// one needs to let the page load its OWN same-origin script/style and make
|
|
64
|
+
// its OWN same-origin fetch() calls, while still denying everything
|
|
65
|
+
// cross-origin — no remote scripts/fonts/analytics, matching
|
|
66
|
+
// frontend/README.md's own "no CDN scripts, remote fonts, analytics" rule.
|
|
67
|
+
//
|
|
68
|
+
// Grepped across the whole frontend/ tree for any inline <script>/
|
|
69
|
+
// style="..." attribute/<style> tag beyond the one index.html inline
|
|
70
|
+
// <script type="module"> block this increment moves into main.js (see
|
|
71
|
+
// item 5 of the plan): none found (confirmed this session, 2026-09-01).
|
|
72
|
+
// So a strict script-src 'self'/style-src 'self' needs no 'unsafe-inline'
|
|
73
|
+
// exception — do not add one without re-running that grep.
|
|
74
|
+
const STATIC_CSP_HEADER_VALUE =
|
|
75
|
+
"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'";
|
|
76
|
+
|
|
77
|
+
// Deliberately restrictive character class (no backslash, no space, no
|
|
78
|
+
// percent sign, no null) — every real filename in frontend/'s inventory is
|
|
79
|
+
// plain ASCII with letters/digits/hyphen/underscore/dot/slash, and keeping
|
|
80
|
+
// the allowlist regex this narrow means an unexpected character in a
|
|
81
|
+
// request path fails allowlisting by default rather than being silently
|
|
82
|
+
// accepted and handed to a platform-specific path.join() later.
|
|
83
|
+
const SRC_JS_RE = /^src\/[A-Za-z0-9/_.-]+\.js$/;
|
|
84
|
+
const STYLES_CSS_RE = /^styles\/[A-Za-z0-9_.-]+\.css$/; // top-level only under styles/, no nested subdirectories — matches the real, current inventory
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolves a request pathname (e.g. `/`, `/src/app.js`, `/styles/tokens.css`)
|
|
88
|
+
* against `frontend/`'s explicit servable-file allowlist. Returns
|
|
89
|
+
* `{ok:true, relativePath, contentType}` or `{ok:false, reason}`.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately an ALLOWLIST, not merely a `../`-traversal guard: `test/`,
|
|
92
|
+
* `scripts/`, `package.json`, `README.md`, `CLAUDE.md`, and `.gitignore` are
|
|
93
|
+
* all real files that exist under FRONTEND_ROOT on disk and must NEVER be
|
|
94
|
+
* served — a naive "stays inside frontend/" confinement check would still
|
|
95
|
+
* happily serve every one of them.
|
|
96
|
+
*/
|
|
97
|
+
function resolveStaticAsset(requestPath) {
|
|
98
|
+
if (typeof requestPath !== 'string' || requestPath.length === 0) {
|
|
99
|
+
return { ok: false, reason: 'invalid-path' };
|
|
100
|
+
}
|
|
101
|
+
if (requestPath.includes('\0')) {
|
|
102
|
+
return { ok: false, reason: 'null-byte' };
|
|
103
|
+
}
|
|
104
|
+
// Absolute-URL-looking input: a scheme (`http://...`) or a
|
|
105
|
+
// protocol-relative `//host/...` — neither is a valid same-origin
|
|
106
|
+
// pathname this server should ever try to resolve.
|
|
107
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(requestPath) || requestPath.startsWith('//')) {
|
|
108
|
+
return { ok: false, reason: 'absolute-url' };
|
|
109
|
+
}
|
|
110
|
+
if (!requestPath.startsWith('/')) {
|
|
111
|
+
return { ok: false, reason: 'invalid-path' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let decoded;
|
|
115
|
+
try {
|
|
116
|
+
decoded = decodeURIComponent(requestPath);
|
|
117
|
+
} catch {
|
|
118
|
+
return { ok: false, reason: 'malformed-encoding' };
|
|
119
|
+
}
|
|
120
|
+
if (decoded.includes('\0')) {
|
|
121
|
+
// Catches a %00-encoded null byte the raw-string check above can't see.
|
|
122
|
+
return { ok: false, reason: 'null-byte' };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (decoded === '/' || decoded === '') {
|
|
126
|
+
return { ok: true, relativePath: 'index.html', contentType: CONTENT_TYPE_MAP['.html'] };
|
|
127
|
+
}
|
|
128
|
+
if (decoded.startsWith('//')) {
|
|
129
|
+
return { ok: false, reason: 'absolute-url' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const candidate = decoded.slice(1); // strip the single leading '/'
|
|
133
|
+
|
|
134
|
+
// Normalize using POSIX rules regardless of host OS — this is a URL path,
|
|
135
|
+
// never a platform filesystem path, and must be treated as forward-slash
|
|
136
|
+
// segments even on a host where path.normalize would use backslashes.
|
|
137
|
+
const normalized = external_node_path_.posix.normalize(candidate);
|
|
138
|
+
|
|
139
|
+
// Re-check for a traversal escape AFTER normalization — never trust
|
|
140
|
+
// normalize() alone to have removed a traversal attempt (a classic
|
|
141
|
+
// traversal-bypass class): explicitly reject anything that still IS, or
|
|
142
|
+
// still CONTAINS, a `..` segment, or that normalized to an absolute path.
|
|
143
|
+
if (
|
|
144
|
+
normalized === '..' ||
|
|
145
|
+
normalized === '.' ||
|
|
146
|
+
normalized.startsWith('../') ||
|
|
147
|
+
normalized.includes('/../') ||
|
|
148
|
+
normalized.endsWith('/..') ||
|
|
149
|
+
normalized.startsWith('/')
|
|
150
|
+
) {
|
|
151
|
+
return { ok: false, reason: 'path-traversal' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// --- The actual ALLOWLIST (default: deny) ---
|
|
155
|
+
|
|
156
|
+
if (normalized === 'index.html') {
|
|
157
|
+
return { ok: true, relativePath: normalized, contentType: CONTENT_TYPE_MAP['.html'] };
|
|
158
|
+
}
|
|
159
|
+
if (SRC_JS_RE.test(normalized)) {
|
|
160
|
+
return { ok: true, relativePath: normalized, contentType: CONTENT_TYPE_MAP['.js'] };
|
|
161
|
+
}
|
|
162
|
+
if (STYLES_CSS_RE.test(normalized)) {
|
|
163
|
+
return { ok: true, relativePath: normalized, contentType: CONTENT_TYPE_MAP['.css'] };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { ok: false, reason: 'not-allowlisted' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
;// CONCATENATED MODULE: ./src/server/http-server.js
|
|
170
|
+
// http-server.js — Milestone 3, sub-project Server, increment 1.
|
|
171
|
+
//
|
|
172
|
+
// The real node:http server for `agentic-security explore`. Binds ONLY to
|
|
173
|
+
// 127.0.0.1 (never 0.0.0.0, never an omitted host arg). Every request is
|
|
174
|
+
// validated (Host header, then session token) before any route handler
|
|
175
|
+
// ever runs. Every response carries CSP + Cache-Control: no-store and
|
|
176
|
+
// NEVER carries Access-Control-Allow-Origin (CORS stays disabled by the
|
|
177
|
+
// absence of that header — see security.js).
|
|
178
|
+
//
|
|
179
|
+
// This is the first node:http usage anywhere in scanner/src/.
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
// Idle-timeout default. No PRD-specified duration exists (confirmed by the
|
|
189
|
+
// scoping doc's own search) — 30 minutes is a reasoned, disclosed default:
|
|
190
|
+
// long enough that a human actually exploring a graph in a browser tab
|
|
191
|
+
// won't get cut off mid-session, short enough that a forgotten terminal
|
|
192
|
+
// doesn't leave a loopback server (and its session token, printed once to
|
|
193
|
+
// stdout/scrollback) listening indefinitely.
|
|
194
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
195
|
+
|
|
196
|
+
// Request-size cap, applied uniformly to every request. Milestone 5's own
|
|
197
|
+
// POST /api/v1/query is the first real consumer of a request body — every
|
|
198
|
+
// pre-M5 GET endpoint has no meaningful body of its own, so this mostly
|
|
199
|
+
// protects that one route today, but the cap applies to any future
|
|
200
|
+
// body-bearing route without retrofitting. 64KB is generous for any
|
|
201
|
+
// header/body this server should ever legitimately receive.
|
|
202
|
+
const MAX_REQUEST_BODY_BYTES = 64 * 1024;
|
|
203
|
+
|
|
204
|
+
// Session token header. A custom header (never a query param) so the
|
|
205
|
+
// token never ends up in server access logs, browser history, or a
|
|
206
|
+
// Referer header the way a query-string token could.
|
|
207
|
+
const TOKEN_HEADER = 'x-agentic-security-token';
|
|
208
|
+
|
|
209
|
+
function _log(method, urlPath, status, durationMs) {
|
|
210
|
+
// Metadata only — NEVER log request/response bodies, NEVER log the
|
|
211
|
+
// session token itself, even on a failed-auth attempt (T3/§17.4).
|
|
212
|
+
console.error(`[explore] ${method} ${urlPath} ${status} ${durationMs}ms`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _sendJson(res, status, bodyObj) {
|
|
216
|
+
const body = JSON.stringify(bodyObj);
|
|
217
|
+
if (!res.headersSent) {
|
|
218
|
+
res.writeHead(status, {
|
|
219
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
220
|
+
'Content-Security-Policy': security/* CSP_HEADER_VALUE */.Xv,
|
|
221
|
+
'Cache-Control': 'no-store',
|
|
222
|
+
'Content-Length': Buffer.byteLength(body),
|
|
223
|
+
// Access-Control-Allow-Origin is intentionally NEVER set anywhere in
|
|
224
|
+
// this file. CORS stays disabled by the absence of this header.
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
res.end(body);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const ROUTES = [
|
|
231
|
+
{ method: 'GET', pattern: /^\/api\/v1\/scan\/?$/, handler: (graph) => (0,routes/* handleScan */.Yo)(graph) },
|
|
232
|
+
{ method: 'GET', pattern: /^\/api\/v1\/graph\/?$/, handler: (graph) => (0,routes/* handleGraph */.fn)(graph) },
|
|
233
|
+
{ method: 'GET', pattern: /^\/api\/v1\/nodes\/([^/]+)\/?$/, handler: (graph, m) => (0,routes/* handleNode */.d5)(graph, decodeURIComponent(m[1])) },
|
|
234
|
+
{ method: 'GET', pattern: /^\/api\/v1\/edges\/([^/]+)\/?$/, handler: (graph, m) => (0,routes/* handleEdge */.Yu)(graph, decodeURIComponent(m[1])) },
|
|
235
|
+
{ method: 'GET', pattern: /^\/api\/v1\/flows\/([^/]+)\/?$/, handler: (graph, m) => (0,routes/* handleFlow */.jg)(graph, decodeURIComponent(m[1])) },
|
|
236
|
+
{ method: 'POST', pattern: /^\/api\/v1\/query\/?$/, handler: (graph, m, body) => (0,routes/* handleQuery */.rR)(graph, body?.filter) },
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Serves one resolved static asset from disk. `resolved` is a real
|
|
241
|
+
* `{ok:true, relativePath, contentType}` from resolveStaticAsset() —
|
|
242
|
+
* already vetted against the allowlist, so the only failure mode left here
|
|
243
|
+
* is the file genuinely missing/unreadable on disk (allowlist/inventory
|
|
244
|
+
* drift), which degrades to a 404 rather than a 500.
|
|
245
|
+
*
|
|
246
|
+
* `relativePath` is built entirely from static-assets.js's own restricted
|
|
247
|
+
* character class (no backslash, already traversal-checked) before it ever
|
|
248
|
+
* reaches this function, so joining it onto FRONTEND_ROOT with the platform
|
|
249
|
+
* path.join is safe.
|
|
250
|
+
*/
|
|
251
|
+
function _serveStaticAsset(res, resolved) {
|
|
252
|
+
let body;
|
|
253
|
+
try {
|
|
254
|
+
body = external_node_fs_.readFileSync(external_node_path_.join(FRONTEND_ROOT, resolved.relativePath));
|
|
255
|
+
} catch {
|
|
256
|
+
_sendJson(res, 404, { error: 'not found' });
|
|
257
|
+
return 404;
|
|
258
|
+
}
|
|
259
|
+
if (!res.headersSent) {
|
|
260
|
+
res.writeHead(200, {
|
|
261
|
+
'Content-Type': resolved.contentType,
|
|
262
|
+
// The STATIC CSP (same-origin-permitting), NOT the JSON API's
|
|
263
|
+
// `default-src 'none'` — this response is HTML/JS/CSS, not JSON.
|
|
264
|
+
'Content-Security-Policy': STATIC_CSP_HEADER_VALUE,
|
|
265
|
+
'Cache-Control': 'no-store',
|
|
266
|
+
'Content-Length': body.length,
|
|
267
|
+
// Access-Control-Allow-Origin is intentionally NEVER set anywhere in
|
|
268
|
+
// this file, on any response, static or JSON alike.
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
res.end(body);
|
|
272
|
+
return 200;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Creates and starts the `explore` HTTP server. Returns a Promise that
|
|
277
|
+
* resolves once the server is actually listening, with `{server, port}`
|
|
278
|
+
* (the REAL bound port, resolved from the OS when `port: 0`/omitted was
|
|
279
|
+
* requested).
|
|
280
|
+
*
|
|
281
|
+
* @param {object} opts
|
|
282
|
+
* @param {object} opts.graph - the verified, in-memory lineage graph
|
|
283
|
+
* @param {number} [opts.port=0] - 0 lets the OS choose a random free port
|
|
284
|
+
* @param {string} opts.sessionToken - required on every request
|
|
285
|
+
* @param {number} [opts.idleTimeoutMs=DEFAULT_IDLE_TIMEOUT_MS]
|
|
286
|
+
* @param {boolean} [opts.keepOpen=false] - suppresses idle-timeout auto-stop
|
|
287
|
+
*/
|
|
288
|
+
function createExploreServer({
|
|
289
|
+
graph,
|
|
290
|
+
port = 0,
|
|
291
|
+
sessionToken,
|
|
292
|
+
idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
|
|
293
|
+
keepOpen = false,
|
|
294
|
+
} = {}) {
|
|
295
|
+
if (!graph || typeof graph !== 'object') {
|
|
296
|
+
throw new Error('createExploreServer requires a graph object');
|
|
297
|
+
}
|
|
298
|
+
if (typeof sessionToken !== 'string' || sessionToken.length < 32) {
|
|
299
|
+
throw new Error('createExploreServer requires a real sessionToken (>= 32 chars)');
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let idleTimer = null;
|
|
303
|
+
|
|
304
|
+
function _clearIdleTimer() {
|
|
305
|
+
if (idleTimer) {
|
|
306
|
+
clearTimeout(idleTimer);
|
|
307
|
+
idleTimer = null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Reset the idle-auto-stop timer. Only called for requests that pass
|
|
312
|
+
// BOTH the Host and session-token checks — an unauthenticated request
|
|
313
|
+
// (a DNS-rebinding probe, a stray scan) must never be able to keep the
|
|
314
|
+
// server alive indefinitely by hammering it; only genuine, authenticated
|
|
315
|
+
// activity extends the session.
|
|
316
|
+
function _resetIdleTimer() {
|
|
317
|
+
if (keepOpen) return;
|
|
318
|
+
_clearIdleTimer();
|
|
319
|
+
idleTimer = setTimeout(() => {
|
|
320
|
+
try { server.closeAllConnections?.(); } catch { /* best-effort */ }
|
|
321
|
+
server.close();
|
|
322
|
+
}, idleTimeoutMs);
|
|
323
|
+
// Don't let this timer alone keep the Node process alive past a clean
|
|
324
|
+
// shutdown path.
|
|
325
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const server = external_node_http_.createServer((req, res) => {
|
|
329
|
+
const start = Date.now();
|
|
330
|
+
const method = req.method || 'GET';
|
|
331
|
+
let urlPath = '/';
|
|
332
|
+
try {
|
|
333
|
+
urlPath = new URL(req.url, 'http://internal').pathname;
|
|
334
|
+
} catch {
|
|
335
|
+
urlPath = req.url || '/';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const finish = (status) => _log(method, urlPath, status, Date.now() - start);
|
|
339
|
+
|
|
340
|
+
// The actual bound port. Cheap (no syscall — Node caches the bound
|
|
341
|
+
// address on the server object), so it's safe to read per-request
|
|
342
|
+
// rather than caching it in a closure variable that could theoretically
|
|
343
|
+
// race the 'listening' event.
|
|
344
|
+
const boundPort = server.address()?.port;
|
|
345
|
+
|
|
346
|
+
// 1. Host header validation (T2 defense) — before anything else.
|
|
347
|
+
const hostHeader = req.headers.host;
|
|
348
|
+
if (!(0,security/* isValidHost */.HO)(hostHeader, boundPort)) {
|
|
349
|
+
_sendJson(res, 400, { error: 'invalid Host header' });
|
|
350
|
+
finish(400);
|
|
351
|
+
req.resume(); // drain and discard any body without processing it
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// 1.5. Static-asset serving (Wire's own new surface) — the ONE
|
|
356
|
+
// deliberate, disclosed exception to "every request requires a session
|
|
357
|
+
// token" below. The token travels to the browser via a URL FRAGMENT
|
|
358
|
+
// (`#token=...`), which is NEVER sent to the server by the browser in
|
|
359
|
+
// any HTTP request — so the very first page-load request (GET /) is
|
|
360
|
+
// structurally incapable of carrying it. Every subsequent same-origin
|
|
361
|
+
// fetch() the page itself makes against /api/v1/* still requires the
|
|
362
|
+
// token, completely unchanged from S1.
|
|
363
|
+
//
|
|
364
|
+
// Namespaced away from /api/v1/* so there is no ambiguity: any GET
|
|
365
|
+
// request NOT under /api/ is resolved against the static-asset
|
|
366
|
+
// allowlist and NEVER reaches the token check at all, matching or
|
|
367
|
+
// rejecting with a 404 (never a 403 — a 403 would confirm to a prober
|
|
368
|
+
// that a rejected path exists). This still runs strictly AFTER the
|
|
369
|
+
// Host-header check above (T2 applies uniformly, auth or not — DNS
|
|
370
|
+
// rebinding does not care whether the resource behind it needs a
|
|
371
|
+
// token).
|
|
372
|
+
if (method === 'GET' && !urlPath.startsWith('/api/')) {
|
|
373
|
+
const staticResult = resolveStaticAsset(urlPath);
|
|
374
|
+
if (staticResult.ok) {
|
|
375
|
+
const status = _serveStaticAsset(res, staticResult);
|
|
376
|
+
finish(status);
|
|
377
|
+
} else {
|
|
378
|
+
_sendJson(res, 404, { error: 'not found' });
|
|
379
|
+
finish(404);
|
|
380
|
+
}
|
|
381
|
+
req.resume();
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// 2. Session-token validation (T3 defense) — required on EVERY
|
|
386
|
+
// request, read or write, per the threat-model doc's own §17.4
|
|
387
|
+
// checklist. Compared in constant time.
|
|
388
|
+
const provided = req.headers[TOKEN_HEADER];
|
|
389
|
+
if (typeof provided !== 'string' || !(0,security/* constantTimeEqual */.SK)(provided, sessionToken)) {
|
|
390
|
+
_sendJson(res, 401, { error: 'missing or invalid session token' });
|
|
391
|
+
finish(401);
|
|
392
|
+
req.resume();
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Genuine, authenticated request — extend the idle-auto-stop window.
|
|
397
|
+
_resetIdleTimer();
|
|
398
|
+
|
|
399
|
+
// 3. Request-size cap, applied uniformly (S1's GET endpoints have no
|
|
400
|
+
// meaningful body; Milestone 5's own POST /api/v1/query is the first
|
|
401
|
+
// real consumer). Milestone 5 also starts accumulating the actual body
|
|
402
|
+
// BYTES (not just the size) — no earlier route needed them.
|
|
403
|
+
let bodySize = 0;
|
|
404
|
+
let bodyChunks = [];
|
|
405
|
+
let aborted = false;
|
|
406
|
+
req.on('data', (chunk) => {
|
|
407
|
+
if (aborted) return;
|
|
408
|
+
bodySize += chunk.length;
|
|
409
|
+
if (bodySize > MAX_REQUEST_BODY_BYTES) {
|
|
410
|
+
aborted = true;
|
|
411
|
+
_sendJson(res, 413, { error: 'request body too large' });
|
|
412
|
+
finish(413);
|
|
413
|
+
// Stop reading further body bytes only once the 413 response has
|
|
414
|
+
// actually been flushed to the client — destroying the request
|
|
415
|
+
// stream immediately can race the response write and cut it off
|
|
416
|
+
// before the client sees it.
|
|
417
|
+
res.once('finish', () => { try { req.destroy(); } catch { /* best-effort */ } });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
bodyChunks.push(chunk);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
req.on('end', () => {
|
|
424
|
+
if (aborted) return;
|
|
425
|
+
|
|
426
|
+
// 4. Route.
|
|
427
|
+
let matched = null;
|
|
428
|
+
let match = null;
|
|
429
|
+
for (const route of ROUTES) {
|
|
430
|
+
if (route.method !== method) continue;
|
|
431
|
+
const m = route.pattern.exec(urlPath);
|
|
432
|
+
if (m) { matched = route; match = m; break; }
|
|
433
|
+
}
|
|
434
|
+
if (!matched) {
|
|
435
|
+
_sendJson(res, 404, { error: 'not found' });
|
|
436
|
+
finish(404);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Milestone 5: parse the body ONLY for a matched route, and only as
|
|
441
|
+
// JSON when non-empty — every pre-M5 GET route still ignores this
|
|
442
|
+
// 3rd argument entirely, so an empty/missing body for them is a
|
|
443
|
+
// no-op, not an error. Disclosed, real, low-risk behavior change
|
|
444
|
+
// (final whole-branch review finding): this JSON-parse pass applies
|
|
445
|
+
// to ANY matched route with a non-empty body, not just the new POST
|
|
446
|
+
// /api/v1/query — a GET request that (unusually) carries a
|
|
447
|
+
// non-JSON-parseable body now gets a clean 400 instead of the
|
|
448
|
+
// pre-M5 behavior (body silently drained, request processed
|
|
449
|
+
// normally). No real client sends a body on a GET here
|
|
450
|
+
// (frontend/src/lib/api-client.js's own fetch() calls never do), so
|
|
451
|
+
// this is not expected to affect any real caller — narrower
|
|
452
|
+
// per-method gating was judged unnecessary complexity for a case
|
|
453
|
+
// with no real-world traffic, but is a real, disclosed option if
|
|
454
|
+
// this ever needs revisiting.
|
|
455
|
+
let body;
|
|
456
|
+
if (bodyChunks.length > 0) {
|
|
457
|
+
try {
|
|
458
|
+
body = JSON.parse(Buffer.concat(bodyChunks).toString('utf8'));
|
|
459
|
+
} catch {
|
|
460
|
+
_sendJson(res, 400, { error: 'malformed JSON request body' });
|
|
461
|
+
finish(400);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
const result = matched.handler(graph, match, body);
|
|
468
|
+
_sendJson(res, result.status, result.body);
|
|
469
|
+
finish(result.status);
|
|
470
|
+
} catch {
|
|
471
|
+
_sendJson(res, 500, { error: 'internal error' });
|
|
472
|
+
finish(500);
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
req.on('error', () => {
|
|
477
|
+
// A client that aborts mid-request — nothing to respond to.
|
|
478
|
+
aborted = true;
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
const onError = (err) => reject(err);
|
|
484
|
+
server.once('error', onError);
|
|
485
|
+
// Bind explicitly to 127.0.0.1 — never omit the host argument (that is
|
|
486
|
+
// NOT the same as 127.0.0.1 on every platform) and never 0.0.0.0.
|
|
487
|
+
server.listen(port, '127.0.0.1', () => {
|
|
488
|
+
server.removeListener('error', onError);
|
|
489
|
+
const actualPort = server.address().port;
|
|
490
|
+
_resetIdleTimer(); // start the idle clock even before the first request
|
|
491
|
+
resolve({ server, port: actualPort });
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
/***/ }),
|
|
498
|
+
|
|
499
|
+
/***/ 4268:
|
|
500
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
501
|
+
|
|
502
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
503
|
+
/* harmony export */ Yo: () => (/* binding */ handleScan),
|
|
504
|
+
/* harmony export */ Yu: () => (/* binding */ handleEdge),
|
|
505
|
+
/* harmony export */ d5: () => (/* binding */ handleNode),
|
|
506
|
+
/* harmony export */ fn: () => (/* binding */ handleGraph),
|
|
507
|
+
/* harmony export */ jg: () => (/* binding */ handleFlow),
|
|
508
|
+
/* harmony export */ rR: () => (/* binding */ handleQuery)
|
|
509
|
+
/* harmony export */ });
|
|
510
|
+
/* unused harmony export wrapResponse */
|
|
511
|
+
/* harmony import */ var _lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(859);
|
|
512
|
+
// routes.js — Milestone 3, sub-project Server, increment 1.
|
|
513
|
+
//
|
|
514
|
+
// Five pure GET-endpoint handlers, each `(graph, ...) -> {status, body}`.
|
|
515
|
+
// No req/res access anywhere in this file — that is what makes these
|
|
516
|
+
// handlers unit-testable without an HTTP layer at all. http-server.js is
|
|
517
|
+
// the only module that touches node:http and calls into these.
|
|
518
|
+
//
|
|
519
|
+
// Every response body is wrapped in `wrapResponse`, which adds the exact
|
|
520
|
+
// envelope fields PRD line 1326 names (quoted in the implementation plan):
|
|
521
|
+
// "base graph/snapshot digest, schema/extension versions, scope, coverage,
|
|
522
|
+
// limitations, and contributing canonical IDs."
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Shared response envelope. Maps PRD line 1326's required fields onto the
|
|
528
|
+
* graph's own real fields:
|
|
529
|
+
* - digest -> graph.graphId (the base graph/snapshot digest)
|
|
530
|
+
* - schemaVersion -> graph.schemaVersion
|
|
531
|
+
* - extensions -> graph.extensions (schema/extension versions —
|
|
532
|
+
* today always `{}`; see schema.js)
|
|
533
|
+
* - scope -> graph.scope
|
|
534
|
+
* - coverage -> graph.coverage
|
|
535
|
+
* - limitations -> graph.limitations
|
|
536
|
+
* - canonicalIds -> see the design note below
|
|
537
|
+
*
|
|
538
|
+
* "contributing canonical IDs" design decision (disclosed per the plan):
|
|
539
|
+
* for `handleScan`/`handleGraph`, which describe the WHOLE graph rather
|
|
540
|
+
* than one entity, `canonicalIds` is `null` — the response body for
|
|
541
|
+
* `handleGraph` already IS the full nodes/edges/flows arrays, so echoing
|
|
542
|
+
* every id again here would be pure duplication with no informational
|
|
543
|
+
* gain, and for a large graph would materially bloat the response for
|
|
544
|
+
* zero benefit. For `handleNode`/`handleEdge`, `canonicalIds` is the
|
|
545
|
+
* single id the response is about. For `handleFlow`, `canonicalIds` is
|
|
546
|
+
* the flow's own id PLUS the node/edge ids that flow's evidence draws
|
|
547
|
+
* from (source, sink, edgeIds) — a flow is a derived record referencing
|
|
548
|
+
* several underlying entities, and naming all of them here is genuinely
|
|
549
|
+
* useful metadata a client would otherwise have to re-derive from the
|
|
550
|
+
* flow body itself.
|
|
551
|
+
*/
|
|
552
|
+
function wrapResponse(data, graph, { canonicalIds = null } = {}) {
|
|
553
|
+
return {
|
|
554
|
+
digest: graph?.graphId ?? null,
|
|
555
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
556
|
+
extensions: graph?.extensions ?? {},
|
|
557
|
+
scope: graph?.scope ?? null,
|
|
558
|
+
coverage: graph?.coverage ?? null,
|
|
559
|
+
limitations: graph?.limitations ?? [],
|
|
560
|
+
canonicalIds,
|
|
561
|
+
data,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function _findById(list, id) {
|
|
566
|
+
if (!Array.isArray(list)) return null;
|
|
567
|
+
return list.find((item) => item && item.id === id) ?? null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Scan/graph metadata — NOT the full node/edge arrays. */
|
|
571
|
+
function handleScan(graph) {
|
|
572
|
+
const data = {
|
|
573
|
+
schemaVersion: graph?.schemaVersion ?? null,
|
|
574
|
+
graphId: graph?.graphId ?? null,
|
|
575
|
+
generatedAt: graph?.generatedAt ?? null,
|
|
576
|
+
scope: graph?.scope ?? null,
|
|
577
|
+
scanHealth: graph?.scanHealth ?? null,
|
|
578
|
+
coverage: graph?.coverage ?? null,
|
|
579
|
+
};
|
|
580
|
+
return { status: 200, body: wrapResponse(data, graph, { canonicalIds: null }) };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** The full graph document, unfiltered. For a scoped/narrowed projection, use `handleQuery` (`POST /api/v1/query`, Milestone 5) below instead. */
|
|
584
|
+
function handleGraph(graph) {
|
|
585
|
+
return { status: 200, body: wrapResponse(graph, graph, { canonicalIds: null }) };
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* A deterministic typed projection query — Milestone 5's own
|
|
590
|
+
* `POST /api/v1/query`, the S2 endpoint `handleGraph`'s own header
|
|
591
|
+
* comment named and deferred. `filter` is the exact `{nodeIds, edgeIds}`
|
|
592
|
+
* shape `dataflow export --filter`/`exportGraphJSON` already use — reused
|
|
593
|
+
* via `_filterGraph`, never reimplemented. Final whole-branch review
|
|
594
|
+
* finding: `undefined` (filter omitted entirely) returns the WHOLE graph,
|
|
595
|
+
* identical to `handleGraph` — but `{}` (an empty, well-formed filter
|
|
596
|
+
* object) is NOT the same thing, and does NOT mean "no restriction": both
|
|
597
|
+
* `nodeIds`/`edgeIds` default to empty Sets inside `_filterGraph`, so `{}`
|
|
598
|
+
* narrows the graph down to EMPTY node/edge/flow/dataElement arrays. A
|
|
599
|
+
* caller that wants the whole graph must omit `filter` entirely, never
|
|
600
|
+
* pass `{}` meaning "everything." A malformed filter is a 400, never a
|
|
601
|
+
* thrown exception reaching the caller.
|
|
602
|
+
*/
|
|
603
|
+
function handleQuery(graph, filter) {
|
|
604
|
+
const check = (0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__.validateFilterShape)(filter);
|
|
605
|
+
if (!check.valid) {
|
|
606
|
+
return { status: 400, body: { error: check.error } };
|
|
607
|
+
}
|
|
608
|
+
return { status: 200, body: wrapResponse((0,_lineage_export_json_js__WEBPACK_IMPORTED_MODULE_0__/* ._filterGraph */ .e)(graph, filter), graph, { canonicalIds: null }) };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/** Look up one node by id. 404 with a clear body if not found. */
|
|
612
|
+
function handleNode(graph, id) {
|
|
613
|
+
const node = _findById(graph?.nodes, id);
|
|
614
|
+
if (!node) {
|
|
615
|
+
return { status: 404, body: wrapResponse({ error: `node not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
616
|
+
}
|
|
617
|
+
return { status: 200, body: wrapResponse(node, graph, { canonicalIds: [id] }) };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Look up one edge by id. 404 with a clear body if not found. */
|
|
621
|
+
function handleEdge(graph, id) {
|
|
622
|
+
const edge = _findById(graph?.edges, id);
|
|
623
|
+
if (!edge) {
|
|
624
|
+
return { status: 404, body: wrapResponse({ error: `edge not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
625
|
+
}
|
|
626
|
+
return { status: 200, body: wrapResponse(edge, graph, { canonicalIds: [id] }) };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** Look up one flow by id. 404 with a clear body if not found. */
|
|
630
|
+
function handleFlow(graph, id) {
|
|
631
|
+
const flow = _findById(graph?.flows, id);
|
|
632
|
+
if (!flow) {
|
|
633
|
+
return { status: 404, body: wrapResponse({ error: `flow not found: ${id}` }, graph, { canonicalIds: [] }) };
|
|
634
|
+
}
|
|
635
|
+
const contributing = new Set([id]);
|
|
636
|
+
if (flow.source) contributing.add(flow.source);
|
|
637
|
+
if (flow.sink) contributing.add(flow.sink);
|
|
638
|
+
for (const eid of (flow.edgeIds || [])) contributing.add(eid);
|
|
639
|
+
return { status: 200, body: wrapResponse(flow, graph, { canonicalIds: [...contributing] }) };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
/***/ }),
|
|
644
|
+
|
|
645
|
+
/***/ 6944:
|
|
646
|
+
/***/ ((__unused_webpack___webpack_module__, __webpack_exports__, __webpack_require__) => {
|
|
647
|
+
|
|
648
|
+
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
649
|
+
/* harmony export */ HO: () => (/* binding */ isValidHost),
|
|
650
|
+
/* harmony export */ SK: () => (/* binding */ constantTimeEqual),
|
|
651
|
+
/* harmony export */ Xv: () => (/* binding */ CSP_HEADER_VALUE),
|
|
652
|
+
/* harmony export */ generateSessionToken: () => (/* binding */ generateSessionToken)
|
|
653
|
+
/* harmony export */ });
|
|
654
|
+
/* harmony import */ var node_crypto__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(7598);
|
|
655
|
+
// security.js — Milestone 3, sub-project Server, increment 1.
|
|
656
|
+
//
|
|
657
|
+
// Security primitives for the `explore` loopback HTTP server: session-token
|
|
658
|
+
// generation/comparison and Host-header validation (the T2 DNS-rebinding
|
|
659
|
+
// defense — see docs/DATA_FLOW_EXPLORER_THREAT_MODEL.md). No node:http
|
|
660
|
+
// usage here — this module is pure and independently unit-testable.
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
/** 32 random bytes, hex-encoded (64 hex chars) — the per-session token. */
|
|
665
|
+
function generateSessionToken() {
|
|
666
|
+
return node_crypto__WEBPACK_IMPORTED_MODULE_0__.randomBytes(32).toString('hex');
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Constant-time string comparison. Wraps crypto.timingSafeEqual, which
|
|
671
|
+
* THROWS on unequal-length buffers — guarded here the same way
|
|
672
|
+
* posture/integrity.js's verifyLastScan guards its own HMAC comparison
|
|
673
|
+
* (`stored.length !== expected.length`), which this function mirrors as
|
|
674
|
+
* its template. Never throws; returns false on any malformed input.
|
|
675
|
+
*/
|
|
676
|
+
function constantTimeEqual(a, b) {
|
|
677
|
+
if (typeof a !== 'string' || typeof b !== 'string') return false;
|
|
678
|
+
const bufA = Buffer.from(a, 'utf8');
|
|
679
|
+
const bufB = Buffer.from(b, 'utf8');
|
|
680
|
+
if (bufA.length !== bufB.length) return false;
|
|
681
|
+
try {
|
|
682
|
+
return node_crypto__WEBPACK_IMPORTED_MODULE_0__.timingSafeEqual(bufA, bufB);
|
|
683
|
+
} catch {
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Loopback hostnames this server ever legitimately answers to. Anything
|
|
689
|
+
// else in a Host header is either a stray/misconfigured client or a
|
|
690
|
+
// DNS-rebinding attempt from a hostile page — reject it (T2).
|
|
691
|
+
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1']);
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Validates a request's `Host` header against the port the server is
|
|
695
|
+
* actually listening on. Accepts only `127.0.0.1`/`localhost`/`::1`
|
|
696
|
+
* (`::1` written either bare or bracketed, `[::1]`), each carrying the
|
|
697
|
+
* expected port explicitly — this is the ENTIRE T2 (DNS rebinding)
|
|
698
|
+
* defense: a hostile page that points a fetch() at 127.0.0.1:<port> still
|
|
699
|
+
* sends the ATTACKER'S origin's Host header value (its own hostname), not
|
|
700
|
+
* "127.0.0.1", so this check rejects it regardless of same-origin policy
|
|
701
|
+
* quirks in any particular browser.
|
|
702
|
+
*
|
|
703
|
+
* A bare host with no port suffix is accepted ONLY when expectedPort is
|
|
704
|
+
* the default HTTP port (80) — browsers omit :80 for a default-port
|
|
705
|
+
* http:// request. In practice this server always listens on a
|
|
706
|
+
* non-default port, so that branch is effectively unreachable but is kept
|
|
707
|
+
* for RFC-correctness rather than special-cased away.
|
|
708
|
+
*
|
|
709
|
+
* @param {string|undefined} hostHeader
|
|
710
|
+
* @param {number} expectedPort
|
|
711
|
+
* @returns {boolean}
|
|
712
|
+
*/
|
|
713
|
+
function isValidHost(hostHeader, expectedPort) {
|
|
714
|
+
if (typeof hostHeader !== 'string' || hostHeader.length === 0) return false;
|
|
715
|
+
if (typeof expectedPort !== 'number' || !Number.isInteger(expectedPort) || expectedPort <= 0) return false;
|
|
716
|
+
|
|
717
|
+
const host = hostHeader.trim();
|
|
718
|
+
if (host.length !== hostHeader.length) return false; // reject leading/trailing whitespace outright
|
|
719
|
+
if (/\s/.test(host)) return false; // no embedded whitespace/control chars in a well-formed Host header
|
|
720
|
+
|
|
721
|
+
let hostname;
|
|
722
|
+
let port = null;
|
|
723
|
+
|
|
724
|
+
if (host.startsWith('[')) {
|
|
725
|
+
// Bracketed IPv6 literal form: [::1] or [::1]:port
|
|
726
|
+
const closeIdx = host.indexOf(']');
|
|
727
|
+
if (closeIdx === -1) return false;
|
|
728
|
+
hostname = host.slice(1, closeIdx); // strip brackets
|
|
729
|
+
const rest = host.slice(closeIdx + 1);
|
|
730
|
+
if (rest.length) {
|
|
731
|
+
if (!rest.startsWith(':')) return false;
|
|
732
|
+
const portStr = rest.slice(1);
|
|
733
|
+
if (!/^\d+$/.test(portStr)) return false;
|
|
734
|
+
port = Number(portStr);
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
const parts = host.split(':');
|
|
738
|
+
if (parts.length === 1) {
|
|
739
|
+
hostname = parts[0];
|
|
740
|
+
} else if (parts.length === 2) {
|
|
741
|
+
hostname = parts[0];
|
|
742
|
+
if (!/^\d+$/.test(parts[1])) return false;
|
|
743
|
+
port = Number(parts[1]);
|
|
744
|
+
} else {
|
|
745
|
+
// More than one colon with no brackets: either a malformed header or
|
|
746
|
+
// a bare (unbracketed) IPv6 literal, which is not valid Host syntax
|
|
747
|
+
// (RFC 7230 §5.4 requires brackets for an IPv6 literal). Reject.
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
hostname = hostname.toLowerCase();
|
|
753
|
+
if (!LOOPBACK_HOSTS.has(hostname)) return false;
|
|
754
|
+
|
|
755
|
+
if (port === null) return expectedPort === 80;
|
|
756
|
+
return port === expectedPort;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// This server serves JSON only in this increment (no HTML, no inline
|
|
760
|
+
// script/style, no static assets) — `frontend/`'s own static assets are a
|
|
761
|
+
// later increment's (Wire's) job, not S1's. `default-src 'none'` is
|
|
762
|
+
// therefore the correct, maximally-restrictive policy: nothing this
|
|
763
|
+
// server ever returns should load or execute ANY sub-resource. Revisit
|
|
764
|
+
// only when a future increment starts serving HTML/JS itself.
|
|
765
|
+
const CSP_HEADER_VALUE = "default-src 'none'; frame-ancestors 'none'";
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
/***/ })
|
|
769
|
+
|
|
770
|
+
};
|