@clear-capabilities/agentic-security-scanner 0.144.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.
Files changed (209) hide show
  1. package/CHANGELOG.md +390 -0
  2. package/bin/agentic-security.js +3813 -83
  3. package/dist/1122.index.js +702 -0
  4. package/dist/{301.index.js → 1301.index.js} +2 -2
  5. package/dist/1379.index.js +591 -0
  6. package/dist/{444.index.js → 1444.index.js} +13 -4
  7. package/dist/{660.index.js → 1660.index.js} +2 -2
  8. package/dist/{700.index.js → 1700.index.js} +2 -2
  9. package/dist/{905.index.js → 1905.index.js} +3 -3
  10. package/dist/{920.index.js → 1920.index.js} +3 -3
  11. package/dist/{238.index.js → 2238.index.js} +3 -3
  12. package/dist/2271.index.js +165 -0
  13. package/dist/{985.index.js → 2376.index.js} +1260 -340
  14. package/dist/2432.index.js +793 -0
  15. package/dist/2659.index.js +93 -0
  16. package/dist/{826.index.js → 2826.index.js} +2 -2
  17. package/dist/{830.index.js → 2830.index.js} +2 -2
  18. package/dist/2923.index.js +298 -0
  19. package/dist/{1.index.js → 3001.index.js} +5 -5
  20. package/dist/{117.index.js → 3117.index.js} +3 -3
  21. package/dist/3180.index.js +307 -0
  22. package/dist/3276.index.js +117 -0
  23. package/dist/{415.index.js → 3415.index.js} +2 -2
  24. package/dist/{499.index.js → 3499.index.js} +2 -2
  25. package/dist/3518.index.js +450 -0
  26. package/dist/{526.index.js → 3526.index.js} +14 -6
  27. package/dist/{736.index.js → 3736.index.js} +4 -4
  28. package/dist/{839.index.js → 3839.index.js} +4 -4
  29. package/dist/{113.index.js → 4113.index.js} +14 -6
  30. package/dist/{265.index.js → 4265.index.js} +2 -2
  31. package/dist/{384.index.js → 4384.index.js} +3 -3
  32. package/dist/4547.index.js +268 -0
  33. package/dist/4863.index.js +422 -0
  34. package/dist/{970.index.js → 4970.index.js} +67 -3
  35. package/dist/5051.index.js +770 -0
  36. package/dist/{144.index.js → 5144.index.js} +5 -5
  37. package/dist/{333.index.js → 5333.index.js} +3 -3
  38. package/dist/5343.index.js +185 -0
  39. package/dist/5350.index.js +866 -0
  40. package/dist/5561.index.js +436 -0
  41. package/dist/{637.index.js → 5637.index.js} +29 -7
  42. package/dist/{449.index.js → 5830.index.js} +78 -14
  43. package/dist/6626.index.js +532 -0
  44. package/dist/6662.index.js +297 -0
  45. package/dist/{675.index.js → 6675.index.js} +5 -5
  46. package/dist/{730.index.js → 6730.index.js} +6 -6
  47. package/dist/6829.index.js +225 -0
  48. package/dist/6944.index.js +130 -0
  49. package/dist/{178.index.js → 7178.index.js} +26 -8
  50. package/dist/{227.index.js → 7227.index.js} +2 -2
  51. package/dist/7310.index.js +520 -0
  52. package/dist/{552.index.js → 7552.index.js} +4 -4
  53. package/dist/7709.index.js +78 -0
  54. package/dist/8218.index.js +160 -0
  55. package/dist/{476.index.js → 8476.index.js} +4 -4
  56. package/dist/{513.index.js → 8513.index.js} +5 -5
  57. package/dist/{520.index.js → 8520.index.js} +2 -2
  58. package/dist/{718.index.js → 8718.index.js} +2 -2
  59. package/dist/{752.index.js → 8752.index.js} +2 -2
  60. package/dist/8846.index.js +100 -0
  61. package/dist/{435.index.js → 9091.index.js} +580 -187
  62. package/dist/{207.index.js → 9207.index.js} +2 -2
  63. package/dist/{220.index.js → 9220.index.js} +2 -2
  64. package/dist/9390.index.js +163 -0
  65. package/dist/{503.index.js → 9503.index.js} +2 -2
  66. package/dist/{801.index.js → 9801.index.js} +2 -2
  67. package/dist/{824.index.js → 9824.index.js} +2 -2
  68. package/dist/agentic-security.mjs +16 -16
  69. package/dist/agentic-security.mjs.sha256 +1 -1
  70. package/dist/compliance-frameworks/hipaa-security-rule.json +3 -2
  71. package/package.json +23 -10
  72. package/src/compare.js +6 -1
  73. package/src/dataflow/CLAUDE.md +2 -2
  74. package/src/dataflow/catalog.js +42 -0
  75. package/src/dataflow/orm-write-catalog.js +175 -0
  76. package/src/engine.js +580 -30
  77. package/src/fix/apply-fix-service.js +1 -0
  78. package/src/history-scan.js +22 -5
  79. package/src/ir/CLAUDE.md +2 -1
  80. package/src/ir/chrome-probe.mjs +150 -0
  81. package/src/ir/parser-js.js +94 -7
  82. package/src/lineage/CLAUDE.md +1203 -0
  83. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  84. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  85. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  86. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  87. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  88. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  89. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  90. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  91. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  92. package/src/lineage/classification.js +56 -0
  93. package/src/lineage/coverage.js +658 -0
  94. package/src/lineage/cross-repo-link.js +107 -0
  95. package/src/lineage/dataflow-graph.schema.json +184 -0
  96. package/src/lineage/decision-story.js +206 -0
  97. package/src/lineage/drift-policy.js +279 -0
  98. package/src/lineage/driver.js +135 -0
  99. package/src/lineage/engine.js +992 -0
  100. package/src/lineage/export-briefing.js +628 -0
  101. package/src/lineage/export-csv.js +62 -0
  102. package/src/lineage/export-json.js +238 -0
  103. package/src/lineage/export-privacy.js +258 -0
  104. package/src/lineage/federation-loader.js +111 -0
  105. package/src/lineage/field-identity.js +78 -0
  106. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  107. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  108. package/src/lineage/flow-grade.js +221 -0
  109. package/src/lineage/governance-edit.js +169 -0
  110. package/src/lineage/graph-builder.js +1114 -0
  111. package/src/lineage/graph-diff.js +431 -0
  112. package/src/lineage/graph-snapshot.js +180 -0
  113. package/src/lineage/handling-analyzer.js +168 -0
  114. package/src/lineage/ids.js +349 -0
  115. package/src/lineage/impact-assessment.js +76 -0
  116. package/src/lineage/impact-engine.js +268 -0
  117. package/src/lineage/index.js +281 -0
  118. package/src/lineage/language-coverage-tiers.js +58 -0
  119. package/src/lineage/obligation-mapping.js +126 -0
  120. package/src/lineage/obligation-predicates.js +235 -0
  121. package/src/lineage/observation-adapters.js +282 -0
  122. package/src/lineage/observation-correlation.js +622 -0
  123. package/src/lineage/observation-store.js +497 -0
  124. package/src/lineage/path-query.js +410 -0
  125. package/src/lineage/path-store.js +400 -0
  126. package/src/lineage/protection.js +53 -0
  127. package/src/lineage/recipient-profile.js +192 -0
  128. package/src/lineage/recipient-registry.js +394 -0
  129. package/src/lineage/redact-graph.js +224 -0
  130. package/src/lineage/remediation.js +417 -0
  131. package/src/lineage/resolve-destination.js +91 -0
  132. package/src/lineage/runtime-observation.js +464 -0
  133. package/src/lineage/scenario-diff.js +84 -0
  134. package/src/lineage/scenario-engine.js +251 -0
  135. package/src/lineage/scenario.js +101 -0
  136. package/src/lineage/schema.js +167 -0
  137. package/src/lineage/sink-registry.js +427 -0
  138. package/src/lineage/source-registry.js +357 -0
  139. package/src/lineage/source-seeding.js +212 -0
  140. package/src/lineage/summaries.js +590 -0
  141. package/src/lineage/transform-catalog.js +397 -0
  142. package/src/lineage/transit-protection.js +150 -0
  143. package/src/lineage/validate.js +285 -0
  144. package/src/lsp/server.js +49 -2
  145. package/src/mcp/CLAUDE.md +7 -1
  146. package/src/mcp/dataflow-tools.js +160 -0
  147. package/src/mcp/server.js +1 -1
  148. package/src/mcp/tools.js +22 -1
  149. package/src/pipeline/assurance-mode.js +64 -1
  150. package/src/pipeline/finding-schema.js +8 -1
  151. package/src/pipeline/scan-health.js +19 -1
  152. package/src/posture/CLAUDE.md +140 -0
  153. package/src/posture/accuracy-scorecard.js +60 -0
  154. package/src/posture/artifact-registry.js +76 -0
  155. package/src/posture/auditor-walkthrough.js +192 -13
  156. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  157. package/src/posture/compliance-policy.js +12 -2
  158. package/src/posture/cross-repo-memory.js +7 -2
  159. package/src/posture/fix-history.js +25 -2
  160. package/src/posture/fix-verify.js +9 -1
  161. package/src/posture/fleet.js +0 -0
  162. package/src/posture/git-history.js +13 -5
  163. package/src/posture/material-change.js +21 -2
  164. package/src/posture/mttr.js +75 -12
  165. package/src/posture/obligation-evidence-pack.js +202 -0
  166. package/src/posture/pre-incident-archaeology.js +39 -7
  167. package/src/posture/privacy-framework.js +14 -0
  168. package/src/posture/provenance/ai-authorship.js +68 -0
  169. package/src/posture/provenance/branch-entry.js +80 -0
  170. package/src/posture/provenance/cache.js +143 -0
  171. package/src/posture/provenance/confidence.js +36 -0
  172. package/src/posture/provenance/coordinator.js +786 -0
  173. package/src/posture/provenance/dag-walk.js +249 -0
  174. package/src/posture/provenance/evidence-attribution.js +59 -0
  175. package/src/posture/provenance/git-evidence.js +310 -0
  176. package/src/posture/provenance/lifecycle.js +208 -0
  177. package/src/posture/provenance/missing-control-resolver.js +137 -0
  178. package/src/posture/provenance/origin-resolver.js +342 -0
  179. package/src/posture/provenance/predicate-replay.js +133 -0
  180. package/src/posture/provenance/providers/config.js +39 -0
  181. package/src/posture/provenance/providers/github.js +62 -0
  182. package/src/posture/provenance/providers/gitlab.js +58 -0
  183. package/src/posture/provenance/repo-lineage.js +74 -0
  184. package/src/posture/provenance/sca-origin.js +139 -0
  185. package/src/posture/provenance/schema.js +255 -0
  186. package/src/posture/provenance/transitive-sca.js +147 -0
  187. package/src/posture/provenance/validate.js +30 -0
  188. package/src/posture/provenance-evidence-bundle.js +144 -0
  189. package/src/posture/remediation-ledger.js +337 -0
  190. package/src/posture/sbom-diff.js +15 -2
  191. package/src/posture/secret-history.js +10 -2
  192. package/src/posture/state-dir.js +38 -14
  193. package/src/posture/vuln-archaeology.js +8 -2
  194. package/src/pr-delta.js +25 -4
  195. package/src/report/index.js +197 -3
  196. package/src/runScan.js +34 -5
  197. package/src/sast/rate-limit.js +33 -3
  198. package/src/server/CLAUDE.md +47 -0
  199. package/src/server/graph-loader.js +141 -0
  200. package/src/server/http-server.js +325 -0
  201. package/src/server/routes.js +129 -0
  202. package/src/server/security.js +111 -0
  203. package/src/server/static-assets.js +139 -0
  204. package/src/util/git-hardening.js +128 -0
  205. package/dist/11.index.js +0 -353
  206. package/dist/259.index.js +0 -975
  207. package/dist/317.index.js +0 -300
  208. package/dist/609.index.js +0 -741
  209. package/dist/838.index.js +0 -152
@@ -0,0 +1,141 @@
1
+ // graph-loader.js — Milestone 3, sub-project Server, increment 1.
2
+ //
3
+ // Reads and VERIFIES the `.agentic-security/lineage-graph.json` artifact
4
+ // before `explore` is allowed to serve a single byte of it. Reuses
5
+ // `posture/integrity.js`'s `verifyLastScan` DIRECTLY (per the plan and the
6
+ // root CLAUDE.md's own instruction) — this module does not implement any
7
+ // signature comparison of its own. `verifyLastScan` already uses
8
+ // `crypto.timingSafeEqual` internally.
9
+ //
10
+ // Loaded ONCE at server startup (see bin/agentic-security.js's cmdExplore)
11
+ // and held in memory for the life of the process — this is a read-only,
12
+ // single-scan-snapshot server; a change to the graph on disk mid-session is
13
+ // out of scope for this increment (threat-model doc's own "P0 is
14
+ // read-only" framing).
15
+
16
+ import * as fs from 'node:fs';
17
+ import { statePath } from '../posture/state-dir.js';
18
+ import { verifyLastScan } from '../posture/integrity.js';
19
+
20
+ /**
21
+ * @param {string} scanRoot
22
+ * @returns {{ok:true, graph:object} | {ok:false, reason:'missing'|'unsigned'|'tampered'|'malformed', message:string}}
23
+ *
24
+ * Four, and only four, distinct failure reasons — each with its own clear
25
+ * message so an operator knows exactly what to do next:
26
+ * - 'missing' — no lineage-graph.json at all. Run a scan with
27
+ * AGENTIC_SECURITY_LINEAGE_DEEP=1 first.
28
+ * - 'unsigned' — the graph exists but its .sig sibling does not
29
+ * (verifyLastScan returns null). Refuse to serve an
30
+ * unverifiable graph.
31
+ * - 'tampered' — the graph exists and has a .sig, but the signature does
32
+ * not match the body (verifyLastScan returns false). The
33
+ * file was modified after signing, or signed under a
34
+ * different install key.
35
+ * - 'malformed' — the body passed signature verification but is not
36
+ * valid JSON. Should not happen from a normal scan; the
37
+ * file may be corrupted on disk after signing.
38
+ */
39
+ export function loadSignedGraph(scanRoot) {
40
+ const graphPath = statePath(scanRoot, 'lineage-graph.json');
41
+ const sigPath = graphPath + '.sig';
42
+
43
+ if (!fs.existsSync(graphPath)) {
44
+ return {
45
+ ok: false,
46
+ reason: 'missing',
47
+ message: `No lineage graph found at ${graphPath}. Run a scan with AGENTIC_SECURITY_LINEAGE_DEEP=1 first (e.g. \`AGENTIC_SECURITY_LINEAGE_DEEP=1 agentic-security scan\`), then re-run \`agentic-security explore\`.`,
48
+ };
49
+ }
50
+
51
+ let body;
52
+ try {
53
+ body = fs.readFileSync(graphPath, 'utf8');
54
+ } catch (e) {
55
+ return {
56
+ ok: false,
57
+ reason: 'missing',
58
+ message: `Lineage graph found at ${graphPath} but could not be read: ${e && e.message ? e.message : e}.`,
59
+ };
60
+ }
61
+
62
+ const verified = verifyLastScan(body, sigPath);
63
+ if (verified === null) {
64
+ return {
65
+ ok: false,
66
+ reason: 'unsigned',
67
+ message: `Lineage graph at ${graphPath} has no signature file (${sigPath} is missing). Refusing to serve an unverifiable graph. Re-run the scan (AGENTIC_SECURITY_LINEAGE_DEEP=1) to regenerate both files together.`,
68
+ };
69
+ }
70
+ if (verified === false) {
71
+ return {
72
+ ok: false,
73
+ reason: 'tampered',
74
+ message: `Lineage graph at ${graphPath} FAILED signature verification — its contents do not match ${sigPath}. The file may have been modified after the scan, or signed under a different install key. Refusing to serve a tampered graph. Re-run the scan to regenerate it.`,
75
+ };
76
+ }
77
+
78
+ let graph;
79
+ try {
80
+ graph = JSON.parse(body);
81
+ } catch (e) {
82
+ return {
83
+ ok: false,
84
+ reason: 'malformed',
85
+ message: `Lineage graph at ${graphPath} passed signature verification but is not valid JSON (${e && e.message ? e.message : e}). This should not happen from a normal scan — the file may be corrupted. Re-run the scan to regenerate it.`,
86
+ };
87
+ }
88
+
89
+ return { ok: true, graph };
90
+ }
91
+
92
+ /**
93
+ * Load .agentic-security/lineage-graph.json ONLY when it is genuinely
94
+ * fresh for THIS scan — never merely because a file happens to exist on
95
+ * disk. Shared by every caller that signs or narrates a graph:-derived
96
+ * compliance claim (M4 sub-project 6c's final whole-branch review found
97
+ * the identical staleness gap independently reachable from
98
+ * `attest --obligations` AND `compliance --walkthrough`, and required
99
+ * this predicate to live in exactly one place rather than being
100
+ * copy-pasted per caller — a safety check that drifts between two
101
+ * near-identical inline copies is worse than one shared bug).
102
+ *
103
+ * `.agentic-security/lineage-graph.json` is only rewritten when a scan
104
+ * actually finishes building a graph (`if (scan.lineageGraph)` in
105
+ * bin/agentic-security.js's persistence code) — an ordinary non-deep
106
+ * rescan, or a deep scan whose lineage build fails, leaves whatever file
107
+ * was there from an earlier successful deep scan untouched. Loading that
108
+ * stale graph and joining it to the CURRENT scan's other data would let a
109
+ * caller assert a graph-derived fact (e.g. "transit protected") about
110
+ * code that has since changed.
111
+ *
112
+ * `enabled: true` in `scan.scanHealth.lineageAnalysis` does NOT by itself
113
+ * mean the build succeeded — engine.js sets it the moment
114
+ * AGENTIC_SECURITY_LINEAGE_DEEP=1 is read, before the build even starts,
115
+ * and leaves it `true` even when the build later throws (only `failure`
116
+ * gets set in that case). `requested && enabled` alone therefore still
117
+ * accepts a stale graph after a failed rebuild — reproduced live via the
118
+ * scan's own already-shipped fault-injection fixture
119
+ * (test/lineage-fault-injection.test.js) before this `failure === null`
120
+ * check was added.
121
+ *
122
+ * @param {string} scanRoot
123
+ * @param {object} scan - the parsed last-scan.json for the CURRENT scan
124
+ * @returns {{graph:object|null, fresh:boolean, loaded:ReturnType<typeof loadSignedGraph>}}
125
+ * `fresh` is true only when a signed graph loaded successfully AND this
126
+ * scan's own scanHealth confirms lineage analysis was requested,
127
+ * enabled, and did not fail. `graph` is `loaded.graph` when fresh, else
128
+ * `null` — never the stale file, even when one exists on disk.
129
+ * `loaded` is the raw `loadSignedGraph` result, so a caller can still
130
+ * distinguish "no file at all" from "a file exists but isn't fresh" for
131
+ * its own disclosure message.
132
+ */
133
+ export function loadFreshLineageGraph(scanRoot, scan) {
134
+ const la = scan?.scanHealth?.lineageAnalysis;
135
+ const requested = la?.requested === true;
136
+ const enabled = la?.enabled === true;
137
+ const failure = la?.failure ?? null;
138
+ const loaded = loadSignedGraph(scanRoot);
139
+ const fresh = loaded.ok && requested && enabled && failure === null;
140
+ return { graph: fresh ? loaded.graph : null, fresh, loaded };
141
+ }
@@ -0,0 +1,325 @@
1
+ // http-server.js — Milestone 3, sub-project Server, increment 1.
2
+ //
3
+ // The real node:http server for `agentic-security explore`. Binds ONLY to
4
+ // 127.0.0.1 (never 0.0.0.0, never an omitted host arg). Every request is
5
+ // validated (Host header, then session token) before any route handler
6
+ // ever runs. Every response carries CSP + Cache-Control: no-store and
7
+ // NEVER carries Access-Control-Allow-Origin (CORS stays disabled by the
8
+ // absence of that header — see security.js).
9
+ //
10
+ // This is the first node:http usage anywhere in scanner/src/.
11
+
12
+ import * as http from 'node:http';
13
+ import * as fs from 'node:fs';
14
+ import * as path from 'node:path';
15
+ import { isValidHost, constantTimeEqual, CSP_HEADER_VALUE } from './security.js';
16
+ import { handleScan, handleGraph, handleNode, handleEdge, handleFlow, handleQuery } from './routes.js';
17
+ import { resolveStaticAsset, FRONTEND_ROOT, STATIC_CSP_HEADER_VALUE } from './static-assets.js';
18
+
19
+ // Idle-timeout default. No PRD-specified duration exists (confirmed by the
20
+ // scoping doc's own search) — 30 minutes is a reasoned, disclosed default:
21
+ // long enough that a human actually exploring a graph in a browser tab
22
+ // won't get cut off mid-session, short enough that a forgotten terminal
23
+ // doesn't leave a loopback server (and its session token, printed once to
24
+ // stdout/scrollback) listening indefinitely.
25
+ export const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
26
+
27
+ // Request-size cap, applied uniformly to every request. Milestone 5's own
28
+ // POST /api/v1/query is the first real consumer of a request body — every
29
+ // pre-M5 GET endpoint has no meaningful body of its own, so this mostly
30
+ // protects that one route today, but the cap applies to any future
31
+ // body-bearing route without retrofitting. 64KB is generous for any
32
+ // header/body this server should ever legitimately receive.
33
+ export const MAX_REQUEST_BODY_BYTES = 64 * 1024;
34
+
35
+ // Session token header. A custom header (never a query param) so the
36
+ // token never ends up in server access logs, browser history, or a
37
+ // Referer header the way a query-string token could.
38
+ export const TOKEN_HEADER = 'x-agentic-security-token';
39
+
40
+ function _log(method, urlPath, status, durationMs) {
41
+ // Metadata only — NEVER log request/response bodies, NEVER log the
42
+ // session token itself, even on a failed-auth attempt (T3/§17.4).
43
+ console.error(`[explore] ${method} ${urlPath} ${status} ${durationMs}ms`);
44
+ }
45
+
46
+ function _sendJson(res, status, bodyObj) {
47
+ const body = JSON.stringify(bodyObj);
48
+ if (!res.headersSent) {
49
+ res.writeHead(status, {
50
+ 'Content-Type': 'application/json; charset=utf-8',
51
+ 'Content-Security-Policy': CSP_HEADER_VALUE,
52
+ 'Cache-Control': 'no-store',
53
+ 'Content-Length': Buffer.byteLength(body),
54
+ // Access-Control-Allow-Origin is intentionally NEVER set anywhere in
55
+ // this file. CORS stays disabled by the absence of this header.
56
+ });
57
+ }
58
+ res.end(body);
59
+ }
60
+
61
+ const ROUTES = [
62
+ { method: 'GET', pattern: /^\/api\/v1\/scan\/?$/, handler: (graph) => handleScan(graph) },
63
+ { method: 'GET', pattern: /^\/api\/v1\/graph\/?$/, handler: (graph) => handleGraph(graph) },
64
+ { method: 'GET', pattern: /^\/api\/v1\/nodes\/([^/]+)\/?$/, handler: (graph, m) => handleNode(graph, decodeURIComponent(m[1])) },
65
+ { method: 'GET', pattern: /^\/api\/v1\/edges\/([^/]+)\/?$/, handler: (graph, m) => handleEdge(graph, decodeURIComponent(m[1])) },
66
+ { method: 'GET', pattern: /^\/api\/v1\/flows\/([^/]+)\/?$/, handler: (graph, m) => handleFlow(graph, decodeURIComponent(m[1])) },
67
+ { method: 'POST', pattern: /^\/api\/v1\/query\/?$/, handler: (graph, m, body) => handleQuery(graph, body?.filter) },
68
+ ];
69
+
70
+ /**
71
+ * Serves one resolved static asset from disk. `resolved` is a real
72
+ * `{ok:true, relativePath, contentType}` from resolveStaticAsset() —
73
+ * already vetted against the allowlist, so the only failure mode left here
74
+ * is the file genuinely missing/unreadable on disk (allowlist/inventory
75
+ * drift), which degrades to a 404 rather than a 500.
76
+ *
77
+ * `relativePath` is built entirely from static-assets.js's own restricted
78
+ * character class (no backslash, already traversal-checked) before it ever
79
+ * reaches this function, so joining it onto FRONTEND_ROOT with the platform
80
+ * path.join is safe.
81
+ */
82
+ function _serveStaticAsset(res, resolved) {
83
+ let body;
84
+ try {
85
+ body = fs.readFileSync(path.join(FRONTEND_ROOT, resolved.relativePath));
86
+ } catch {
87
+ _sendJson(res, 404, { error: 'not found' });
88
+ return 404;
89
+ }
90
+ if (!res.headersSent) {
91
+ res.writeHead(200, {
92
+ 'Content-Type': resolved.contentType,
93
+ // The STATIC CSP (same-origin-permitting), NOT the JSON API's
94
+ // `default-src 'none'` — this response is HTML/JS/CSS, not JSON.
95
+ 'Content-Security-Policy': STATIC_CSP_HEADER_VALUE,
96
+ 'Cache-Control': 'no-store',
97
+ 'Content-Length': body.length,
98
+ // Access-Control-Allow-Origin is intentionally NEVER set anywhere in
99
+ // this file, on any response, static or JSON alike.
100
+ });
101
+ }
102
+ res.end(body);
103
+ return 200;
104
+ }
105
+
106
+ /**
107
+ * Creates and starts the `explore` HTTP server. Returns a Promise that
108
+ * resolves once the server is actually listening, with `{server, port}`
109
+ * (the REAL bound port, resolved from the OS when `port: 0`/omitted was
110
+ * requested).
111
+ *
112
+ * @param {object} opts
113
+ * @param {object} opts.graph - the verified, in-memory lineage graph
114
+ * @param {number} [opts.port=0] - 0 lets the OS choose a random free port
115
+ * @param {string} opts.sessionToken - required on every request
116
+ * @param {number} [opts.idleTimeoutMs=DEFAULT_IDLE_TIMEOUT_MS]
117
+ * @param {boolean} [opts.keepOpen=false] - suppresses idle-timeout auto-stop
118
+ */
119
+ export function createExploreServer({
120
+ graph,
121
+ port = 0,
122
+ sessionToken,
123
+ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
124
+ keepOpen = false,
125
+ } = {}) {
126
+ if (!graph || typeof graph !== 'object') {
127
+ throw new Error('createExploreServer requires a graph object');
128
+ }
129
+ if (typeof sessionToken !== 'string' || sessionToken.length < 32) {
130
+ throw new Error('createExploreServer requires a real sessionToken (>= 32 chars)');
131
+ }
132
+
133
+ let idleTimer = null;
134
+
135
+ function _clearIdleTimer() {
136
+ if (idleTimer) {
137
+ clearTimeout(idleTimer);
138
+ idleTimer = null;
139
+ }
140
+ }
141
+
142
+ // Reset the idle-auto-stop timer. Only called for requests that pass
143
+ // BOTH the Host and session-token checks — an unauthenticated request
144
+ // (a DNS-rebinding probe, a stray scan) must never be able to keep the
145
+ // server alive indefinitely by hammering it; only genuine, authenticated
146
+ // activity extends the session.
147
+ function _resetIdleTimer() {
148
+ if (keepOpen) return;
149
+ _clearIdleTimer();
150
+ idleTimer = setTimeout(() => {
151
+ try { server.closeAllConnections?.(); } catch { /* best-effort */ }
152
+ server.close();
153
+ }, idleTimeoutMs);
154
+ // Don't let this timer alone keep the Node process alive past a clean
155
+ // shutdown path.
156
+ if (typeof idleTimer.unref === 'function') idleTimer.unref();
157
+ }
158
+
159
+ const server = http.createServer((req, res) => {
160
+ const start = Date.now();
161
+ const method = req.method || 'GET';
162
+ let urlPath = '/';
163
+ try {
164
+ urlPath = new URL(req.url, 'http://internal').pathname;
165
+ } catch {
166
+ urlPath = req.url || '/';
167
+ }
168
+
169
+ const finish = (status) => _log(method, urlPath, status, Date.now() - start);
170
+
171
+ // The actual bound port. Cheap (no syscall — Node caches the bound
172
+ // address on the server object), so it's safe to read per-request
173
+ // rather than caching it in a closure variable that could theoretically
174
+ // race the 'listening' event.
175
+ const boundPort = server.address()?.port;
176
+
177
+ // 1. Host header validation (T2 defense) — before anything else.
178
+ const hostHeader = req.headers.host;
179
+ if (!isValidHost(hostHeader, boundPort)) {
180
+ _sendJson(res, 400, { error: 'invalid Host header' });
181
+ finish(400);
182
+ req.resume(); // drain and discard any body without processing it
183
+ return;
184
+ }
185
+
186
+ // 1.5. Static-asset serving (Wire's own new surface) — the ONE
187
+ // deliberate, disclosed exception to "every request requires a session
188
+ // token" below. The token travels to the browser via a URL FRAGMENT
189
+ // (`#token=...`), which is NEVER sent to the server by the browser in
190
+ // any HTTP request — so the very first page-load request (GET /) is
191
+ // structurally incapable of carrying it. Every subsequent same-origin
192
+ // fetch() the page itself makes against /api/v1/* still requires the
193
+ // token, completely unchanged from S1.
194
+ //
195
+ // Namespaced away from /api/v1/* so there is no ambiguity: any GET
196
+ // request NOT under /api/ is resolved against the static-asset
197
+ // allowlist and NEVER reaches the token check at all, matching or
198
+ // rejecting with a 404 (never a 403 — a 403 would confirm to a prober
199
+ // that a rejected path exists). This still runs strictly AFTER the
200
+ // Host-header check above (T2 applies uniformly, auth or not — DNS
201
+ // rebinding does not care whether the resource behind it needs a
202
+ // token).
203
+ if (method === 'GET' && !urlPath.startsWith('/api/')) {
204
+ const staticResult = resolveStaticAsset(urlPath);
205
+ if (staticResult.ok) {
206
+ const status = _serveStaticAsset(res, staticResult);
207
+ finish(status);
208
+ } else {
209
+ _sendJson(res, 404, { error: 'not found' });
210
+ finish(404);
211
+ }
212
+ req.resume();
213
+ return;
214
+ }
215
+
216
+ // 2. Session-token validation (T3 defense) — required on EVERY
217
+ // request, read or write, per the threat-model doc's own §17.4
218
+ // checklist. Compared in constant time.
219
+ const provided = req.headers[TOKEN_HEADER];
220
+ if (typeof provided !== 'string' || !constantTimeEqual(provided, sessionToken)) {
221
+ _sendJson(res, 401, { error: 'missing or invalid session token' });
222
+ finish(401);
223
+ req.resume();
224
+ return;
225
+ }
226
+
227
+ // Genuine, authenticated request — extend the idle-auto-stop window.
228
+ _resetIdleTimer();
229
+
230
+ // 3. Request-size cap, applied uniformly (S1's GET endpoints have no
231
+ // meaningful body; Milestone 5's own POST /api/v1/query is the first
232
+ // real consumer). Milestone 5 also starts accumulating the actual body
233
+ // BYTES (not just the size) — no earlier route needed them.
234
+ let bodySize = 0;
235
+ let bodyChunks = [];
236
+ let aborted = false;
237
+ req.on('data', (chunk) => {
238
+ if (aborted) return;
239
+ bodySize += chunk.length;
240
+ if (bodySize > MAX_REQUEST_BODY_BYTES) {
241
+ aborted = true;
242
+ _sendJson(res, 413, { error: 'request body too large' });
243
+ finish(413);
244
+ // Stop reading further body bytes only once the 413 response has
245
+ // actually been flushed to the client — destroying the request
246
+ // stream immediately can race the response write and cut it off
247
+ // before the client sees it.
248
+ res.once('finish', () => { try { req.destroy(); } catch { /* best-effort */ } });
249
+ return;
250
+ }
251
+ bodyChunks.push(chunk);
252
+ });
253
+
254
+ req.on('end', () => {
255
+ if (aborted) return;
256
+
257
+ // 4. Route.
258
+ let matched = null;
259
+ let match = null;
260
+ for (const route of ROUTES) {
261
+ if (route.method !== method) continue;
262
+ const m = route.pattern.exec(urlPath);
263
+ if (m) { matched = route; match = m; break; }
264
+ }
265
+ if (!matched) {
266
+ _sendJson(res, 404, { error: 'not found' });
267
+ finish(404);
268
+ return;
269
+ }
270
+
271
+ // Milestone 5: parse the body ONLY for a matched route, and only as
272
+ // JSON when non-empty — every pre-M5 GET route still ignores this
273
+ // 3rd argument entirely, so an empty/missing body for them is a
274
+ // no-op, not an error. Disclosed, real, low-risk behavior change
275
+ // (final whole-branch review finding): this JSON-parse pass applies
276
+ // to ANY matched route with a non-empty body, not just the new POST
277
+ // /api/v1/query — a GET request that (unusually) carries a
278
+ // non-JSON-parseable body now gets a clean 400 instead of the
279
+ // pre-M5 behavior (body silently drained, request processed
280
+ // normally). No real client sends a body on a GET here
281
+ // (frontend/src/lib/api-client.js's own fetch() calls never do), so
282
+ // this is not expected to affect any real caller — narrower
283
+ // per-method gating was judged unnecessary complexity for a case
284
+ // with no real-world traffic, but is a real, disclosed option if
285
+ // this ever needs revisiting.
286
+ let body;
287
+ if (bodyChunks.length > 0) {
288
+ try {
289
+ body = JSON.parse(Buffer.concat(bodyChunks).toString('utf8'));
290
+ } catch {
291
+ _sendJson(res, 400, { error: 'malformed JSON request body' });
292
+ finish(400);
293
+ return;
294
+ }
295
+ }
296
+
297
+ try {
298
+ const result = matched.handler(graph, match, body);
299
+ _sendJson(res, result.status, result.body);
300
+ finish(result.status);
301
+ } catch {
302
+ _sendJson(res, 500, { error: 'internal error' });
303
+ finish(500);
304
+ }
305
+ });
306
+
307
+ req.on('error', () => {
308
+ // A client that aborts mid-request — nothing to respond to.
309
+ aborted = true;
310
+ });
311
+ });
312
+
313
+ return new Promise((resolve, reject) => {
314
+ const onError = (err) => reject(err);
315
+ server.once('error', onError);
316
+ // Bind explicitly to 127.0.0.1 — never omit the host argument (that is
317
+ // NOT the same as 127.0.0.1 on every platform) and never 0.0.0.0.
318
+ server.listen(port, '127.0.0.1', () => {
319
+ server.removeListener('error', onError);
320
+ const actualPort = server.address().port;
321
+ _resetIdleTimer(); // start the idle clock even before the first request
322
+ resolve({ server, port: actualPort });
323
+ });
324
+ });
325
+ }
@@ -0,0 +1,129 @@
1
+ // routes.js — Milestone 3, sub-project Server, increment 1.
2
+ //
3
+ // Five pure GET-endpoint handlers, each `(graph, ...) -> {status, body}`.
4
+ // No req/res access anywhere in this file — that is what makes these
5
+ // handlers unit-testable without an HTTP layer at all. http-server.js is
6
+ // the only module that touches node:http and calls into these.
7
+ //
8
+ // Every response body is wrapped in `wrapResponse`, which adds the exact
9
+ // envelope fields PRD line 1326 names (quoted in the implementation plan):
10
+ // "base graph/snapshot digest, schema/extension versions, scope, coverage,
11
+ // limitations, and contributing canonical IDs."
12
+
13
+ import { _filterGraph, validateFilterShape } from '../lineage/export-json.js';
14
+
15
+ /**
16
+ * Shared response envelope. Maps PRD line 1326's required fields onto the
17
+ * graph's own real fields:
18
+ * - digest -> graph.graphId (the base graph/snapshot digest)
19
+ * - schemaVersion -> graph.schemaVersion
20
+ * - extensions -> graph.extensions (schema/extension versions —
21
+ * today always `{}`; see schema.js)
22
+ * - scope -> graph.scope
23
+ * - coverage -> graph.coverage
24
+ * - limitations -> graph.limitations
25
+ * - canonicalIds -> see the design note below
26
+ *
27
+ * "contributing canonical IDs" design decision (disclosed per the plan):
28
+ * for `handleScan`/`handleGraph`, which describe the WHOLE graph rather
29
+ * than one entity, `canonicalIds` is `null` — the response body for
30
+ * `handleGraph` already IS the full nodes/edges/flows arrays, so echoing
31
+ * every id again here would be pure duplication with no informational
32
+ * gain, and for a large graph would materially bloat the response for
33
+ * zero benefit. For `handleNode`/`handleEdge`, `canonicalIds` is the
34
+ * single id the response is about. For `handleFlow`, `canonicalIds` is
35
+ * the flow's own id PLUS the node/edge ids that flow's evidence draws
36
+ * from (source, sink, edgeIds) — a flow is a derived record referencing
37
+ * several underlying entities, and naming all of them here is genuinely
38
+ * useful metadata a client would otherwise have to re-derive from the
39
+ * flow body itself.
40
+ */
41
+ export function wrapResponse(data, graph, { canonicalIds = null } = {}) {
42
+ return {
43
+ digest: graph?.graphId ?? null,
44
+ schemaVersion: graph?.schemaVersion ?? null,
45
+ extensions: graph?.extensions ?? {},
46
+ scope: graph?.scope ?? null,
47
+ coverage: graph?.coverage ?? null,
48
+ limitations: graph?.limitations ?? [],
49
+ canonicalIds,
50
+ data,
51
+ };
52
+ }
53
+
54
+ function _findById(list, id) {
55
+ if (!Array.isArray(list)) return null;
56
+ return list.find((item) => item && item.id === id) ?? null;
57
+ }
58
+
59
+ /** Scan/graph metadata — NOT the full node/edge arrays. */
60
+ export function handleScan(graph) {
61
+ const data = {
62
+ schemaVersion: graph?.schemaVersion ?? null,
63
+ graphId: graph?.graphId ?? null,
64
+ generatedAt: graph?.generatedAt ?? null,
65
+ scope: graph?.scope ?? null,
66
+ scanHealth: graph?.scanHealth ?? null,
67
+ coverage: graph?.coverage ?? null,
68
+ };
69
+ return { status: 200, body: wrapResponse(data, graph, { canonicalIds: null }) };
70
+ }
71
+
72
+ /** The full graph document, unfiltered. For a scoped/narrowed projection, use `handleQuery` (`POST /api/v1/query`, Milestone 5) below instead. */
73
+ export function handleGraph(graph) {
74
+ return { status: 200, body: wrapResponse(graph, graph, { canonicalIds: null }) };
75
+ }
76
+
77
+ /**
78
+ * A deterministic typed projection query — Milestone 5's own
79
+ * `POST /api/v1/query`, the S2 endpoint `handleGraph`'s own header
80
+ * comment named and deferred. `filter` is the exact `{nodeIds, edgeIds}`
81
+ * shape `dataflow export --filter`/`exportGraphJSON` already use — reused
82
+ * via `_filterGraph`, never reimplemented. Final whole-branch review
83
+ * finding: `undefined` (filter omitted entirely) returns the WHOLE graph,
84
+ * identical to `handleGraph` — but `{}` (an empty, well-formed filter
85
+ * object) is NOT the same thing, and does NOT mean "no restriction": both
86
+ * `nodeIds`/`edgeIds` default to empty Sets inside `_filterGraph`, so `{}`
87
+ * narrows the graph down to EMPTY node/edge/flow/dataElement arrays. A
88
+ * caller that wants the whole graph must omit `filter` entirely, never
89
+ * pass `{}` meaning "everything." A malformed filter is a 400, never a
90
+ * thrown exception reaching the caller.
91
+ */
92
+ export function handleQuery(graph, filter) {
93
+ const check = validateFilterShape(filter);
94
+ if (!check.valid) {
95
+ return { status: 400, body: { error: check.error } };
96
+ }
97
+ return { status: 200, body: wrapResponse(_filterGraph(graph, filter), graph, { canonicalIds: null }) };
98
+ }
99
+
100
+ /** Look up one node by id. 404 with a clear body if not found. */
101
+ export function handleNode(graph, id) {
102
+ const node = _findById(graph?.nodes, id);
103
+ if (!node) {
104
+ return { status: 404, body: wrapResponse({ error: `node not found: ${id}` }, graph, { canonicalIds: [] }) };
105
+ }
106
+ return { status: 200, body: wrapResponse(node, graph, { canonicalIds: [id] }) };
107
+ }
108
+
109
+ /** Look up one edge by id. 404 with a clear body if not found. */
110
+ export function handleEdge(graph, id) {
111
+ const edge = _findById(graph?.edges, id);
112
+ if (!edge) {
113
+ return { status: 404, body: wrapResponse({ error: `edge not found: ${id}` }, graph, { canonicalIds: [] }) };
114
+ }
115
+ return { status: 200, body: wrapResponse(edge, graph, { canonicalIds: [id] }) };
116
+ }
117
+
118
+ /** Look up one flow by id. 404 with a clear body if not found. */
119
+ export function handleFlow(graph, id) {
120
+ const flow = _findById(graph?.flows, id);
121
+ if (!flow) {
122
+ return { status: 404, body: wrapResponse({ error: `flow not found: ${id}` }, graph, { canonicalIds: [] }) };
123
+ }
124
+ const contributing = new Set([id]);
125
+ if (flow.source) contributing.add(flow.source);
126
+ if (flow.sink) contributing.add(flow.sink);
127
+ for (const eid of (flow.edgeIds || [])) contributing.add(eid);
128
+ return { status: 200, body: wrapResponse(flow, graph, { canonicalIds: [...contributing] }) };
129
+ }