@clear-capabilities/agentic-security-scanner 0.145.0 → 0.147.5

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 (195) hide show
  1. package/CHANGELOG.md +266 -0
  2. package/bin/agentic-security.js +3508 -69
  3. package/dist/1122.index.js +779 -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} +2 -2
  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/{271.index.js → 2271.index.js} +2 -2
  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 +379 -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} +5 -5
  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} +5 -5
  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} +2 -2
  35. package/dist/5051.index.js +841 -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} +3 -3
  42. package/dist/{449.index.js → 5830.index.js} +2 -2
  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} +3 -3
  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} +560 -189
  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/dist/frontend/index.html +21 -0
  72. package/dist/frontend/src/app.js +176 -0
  73. package/dist/frontend/src/components/evidence-inspector.js +141 -0
  74. package/dist/frontend/src/components/filter-rail.js +119 -0
  75. package/dist/frontend/src/components/query-bar.js +126 -0
  76. package/dist/frontend/src/data/flagship-graph.js +1460 -0
  77. package/dist/frontend/src/export-entry.js +36 -0
  78. package/dist/frontend/src/lib/api-client.js +92 -0
  79. package/dist/frontend/src/lib/contrast.js +34 -0
  80. package/dist/frontend/src/lib/dom.js +24 -0
  81. package/dist/frontend/src/lib/escape-html.js +16 -0
  82. package/dist/frontend/src/lib/flow-path.js +40 -0
  83. package/dist/frontend/src/lib/focus-controls.js +149 -0
  84. package/dist/frontend/src/lib/protection-visual.js +46 -0
  85. package/dist/frontend/src/lib/query-language.js +240 -0
  86. package/dist/frontend/src/lib/row-filters.js +43 -0
  87. package/dist/frontend/src/lib/state.js +84 -0
  88. package/dist/frontend/src/main.js +83 -0
  89. package/dist/frontend/src/shell.js +184 -0
  90. package/dist/frontend/src/views/architecture-view.js +798 -0
  91. package/dist/frontend/src/views/inventory-view.js +292 -0
  92. package/dist/frontend/src/views/privacy-view.js +172 -0
  93. package/dist/frontend/src/views/trace-view.js +206 -0
  94. package/dist/frontend/styles/architecture-view.css +93 -0
  95. package/dist/frontend/styles/filter-rail.css +34 -0
  96. package/dist/frontend/styles/inspector.css +69 -0
  97. package/dist/frontend/styles/inventory-view.css +74 -0
  98. package/dist/frontend/styles/privacy-view.css +86 -0
  99. package/dist/frontend/styles/query-bar.css +107 -0
  100. package/dist/frontend/styles/shell.css +155 -0
  101. package/dist/frontend/styles/tokens.css +128 -0
  102. package/dist/frontend/styles/trace-view.css +95 -0
  103. package/package.json +13 -6
  104. package/src/dataflow/CLAUDE.md +1 -1
  105. package/src/dataflow/catalog.js +42 -0
  106. package/src/dataflow/orm-write-catalog.js +175 -0
  107. package/src/engine.js +92 -1
  108. package/src/ir/CLAUDE.md +1 -0
  109. package/src/ir/chrome-probe.mjs +150 -0
  110. package/src/ir/parser-js.js +94 -7
  111. package/src/lineage/CLAUDE.md +1203 -0
  112. package/src/lineage/DESIGN_DESTINATION_RESOLVER.md +156 -0
  113. package/src/lineage/DESIGN_GRAPH_BUILDER.md +938 -0
  114. package/src/lineage/DESIGN_HANDLING_ANALYZER.md +355 -0
  115. package/src/lineage/DESIGN_INTRAPROCEDURAL.md +628 -0
  116. package/src/lineage/DESIGN_PATH_PROVENANCE.md +3451 -0
  117. package/src/lineage/DESIGN_QUEUE_DETAIL.md +120 -0
  118. package/src/lineage/DESIGN_REGISTRIES.md +880 -0
  119. package/src/lineage/DESIGN_STORE_DETAIL.md +143 -0
  120. package/src/lineage/DESIGN_TRANSIT_PROTECTION.md +245 -0
  121. package/src/lineage/classification.js +56 -0
  122. package/src/lineage/coverage.js +658 -0
  123. package/src/lineage/cross-repo-link.js +107 -0
  124. package/src/lineage/dataflow-graph.schema.json +184 -0
  125. package/src/lineage/decision-story.js +206 -0
  126. package/src/lineage/drift-policy.js +279 -0
  127. package/src/lineage/driver.js +135 -0
  128. package/src/lineage/engine.js +992 -0
  129. package/src/lineage/export-briefing.js +628 -0
  130. package/src/lineage/export-csv.js +62 -0
  131. package/src/lineage/export-json.js +238 -0
  132. package/src/lineage/export-privacy.js +258 -0
  133. package/src/lineage/federation-loader.js +111 -0
  134. package/src/lineage/field-identity.js +78 -0
  135. package/src/lineage/fixtures/build-flagship-fixture.mjs +272 -0
  136. package/src/lineage/fixtures/flagship-graph.json +1453 -0
  137. package/src/lineage/flow-grade.js +221 -0
  138. package/src/lineage/governance-edit.js +169 -0
  139. package/src/lineage/graph-builder.js +1114 -0
  140. package/src/lineage/graph-diff.js +431 -0
  141. package/src/lineage/graph-snapshot.js +180 -0
  142. package/src/lineage/handling-analyzer.js +168 -0
  143. package/src/lineage/ids.js +349 -0
  144. package/src/lineage/impact-assessment.js +76 -0
  145. package/src/lineage/impact-engine.js +268 -0
  146. package/src/lineage/index.js +281 -0
  147. package/src/lineage/language-coverage-tiers.js +58 -0
  148. package/src/lineage/obligation-mapping.js +126 -0
  149. package/src/lineage/obligation-predicates.js +235 -0
  150. package/src/lineage/observation-adapters.js +282 -0
  151. package/src/lineage/observation-correlation.js +622 -0
  152. package/src/lineage/observation-store.js +497 -0
  153. package/src/lineage/path-query.js +410 -0
  154. package/src/lineage/path-store.js +400 -0
  155. package/src/lineage/protection.js +53 -0
  156. package/src/lineage/recipient-profile.js +192 -0
  157. package/src/lineage/recipient-registry.js +394 -0
  158. package/src/lineage/redact-graph.js +224 -0
  159. package/src/lineage/remediation.js +417 -0
  160. package/src/lineage/resolve-destination.js +91 -0
  161. package/src/lineage/runtime-observation.js +464 -0
  162. package/src/lineage/scenario-diff.js +84 -0
  163. package/src/lineage/scenario-engine.js +251 -0
  164. package/src/lineage/scenario.js +101 -0
  165. package/src/lineage/schema.js +167 -0
  166. package/src/lineage/sink-registry.js +427 -0
  167. package/src/lineage/source-registry.js +357 -0
  168. package/src/lineage/source-seeding.js +212 -0
  169. package/src/lineage/summaries.js +590 -0
  170. package/src/lineage/transform-catalog.js +397 -0
  171. package/src/lineage/transit-protection.js +150 -0
  172. package/src/lineage/validate.js +285 -0
  173. package/src/mcp/CLAUDE.md +7 -1
  174. package/src/mcp/dataflow-tools.js +160 -0
  175. package/src/mcp/server.js +1 -1
  176. package/src/mcp/tools.js +2 -1
  177. package/src/pipeline/scan-health.js +19 -1
  178. package/src/posture/CLAUDE.md +19 -0
  179. package/src/posture/artifact-registry.js +52 -0
  180. package/src/posture/auditor-walkthrough.js +76 -0
  181. package/src/posture/compliance-frameworks/hipaa-security-rule.json +3 -2
  182. package/src/posture/obligation-evidence-pack.js +202 -0
  183. package/src/posture/remediation-ledger.js +337 -0
  184. package/src/server/CLAUDE.md +47 -0
  185. package/src/server/graph-loader.js +141 -0
  186. package/src/server/http-server.js +325 -0
  187. package/src/server/routes.js +129 -0
  188. package/src/server/security.js +111 -0
  189. package/src/server/static-assets.js +144 -0
  190. package/src/shared/frontend-root.js +52 -0
  191. package/dist/11.index.js +0 -353
  192. package/dist/259.index.js +0 -975
  193. package/dist/317.index.js +0 -300
  194. package/dist/609.index.js +0 -741
  195. package/dist/838.index.js +0 -152
@@ -0,0 +1,240 @@
1
+ // PRD §15.2's query language: a small DSL for filtering the Data Flow
2
+ // Explorer's views (e.g. `class:PCI AND sink:(log,database,external_api)`).
3
+ // This module is ONLY the tokenizer + recursive-descent parser, producing
4
+ // an AST — no evaluation, no UI. A later increment consumes the AST to
5
+ // actually filter a graph/view.
6
+ //
7
+ // Hand-written tokenizer, not regex-based single-pass lexing — the grammar
8
+ // has quoted strings (which must suppress internal `:`/`(`/`)`/`,`
9
+ // interpretation) and needs real position tracking for actionable error
10
+ // messages, both easier to get right with an explicit character-scanning
11
+ // loop.
12
+ //
13
+ // `parseQuery` NEVER throws to its caller, matching `state.js`'s own
14
+ // convention of never letting a malformed/adversarial user-supplied string
15
+ // (there, a URL hash; here, a query string) crash the caller — a bad query
16
+ // returns a structured `{error: {message, pos}}` instead, so a caller can
17
+ // check `.error` and keep the prior state rather than losing the page.
18
+
19
+ import { isAiRelevantFlow, flowPathNodeIds } from './flow-path.js';
20
+
21
+ const KEYWORDS = new Set(['and', 'or']);
22
+
23
+ export function tokenize(input) {
24
+ const tokens = [];
25
+ let i = 0;
26
+ const isFieldChar = (c) => /[a-zA-Z0-9_.]/.test(c);
27
+
28
+ while (i < input.length) {
29
+ const c = input[i];
30
+ if (c === ' ' || c === '\t' || c === '\n') { i++; continue; }
31
+ if (c === '(') { tokens.push({ type: 'LPAREN', value: '(', pos: i }); i++; continue; }
32
+ if (c === ')') { tokens.push({ type: 'RPAREN', value: ')', pos: i }); i++; continue; }
33
+ if (c === ',') { tokens.push({ type: 'COMMA', value: ',', pos: i }); i++; continue; }
34
+ if (c === '!' && input[i + 1] === '=') { tokens.push({ type: 'OP', value: '!=', pos: i }); i += 2; continue; }
35
+ if (c === ':') { tokens.push({ type: 'OP', value: ':', pos: i }); i++; continue; }
36
+ if (c === '"') {
37
+ const start = i;
38
+ i++;
39
+ let value = '';
40
+ while (i < input.length && input[i] !== '"') { value += input[i]; i++; }
41
+ if (input[i] !== '"') return { error: true, pos: start, message: 'unterminated quoted string' };
42
+ i++; // consume closing quote
43
+ tokens.push({ type: 'VALUE', value, pos: start });
44
+ continue;
45
+ }
46
+ if (isFieldChar(c)) {
47
+ const start = i;
48
+ let word = '';
49
+ while (i < input.length && isFieldChar(input[i])) { word += input[i]; i++; }
50
+ const lower = word.toLowerCase();
51
+ if (lower === 'and') tokens.push({ type: 'AND', value: word, pos: start });
52
+ else if (lower === 'or') tokens.push({ type: 'OR', value: word, pos: start });
53
+ else {
54
+ // A bare word followed by ':' or '!=' is a FIELD; otherwise it's a
55
+ // TEXT-search VALUE (a later increment evaluates TEXT nodes; the
56
+ // parser just needs to tell the two shapes apart here by lookahead).
57
+ const nextNonSpace = input.slice(i).match(/^\s*(:|!=)/);
58
+ tokens.push({ type: nextNonSpace ? 'FIELD' : 'VALUE', value: word, pos: start });
59
+ }
60
+ continue;
61
+ }
62
+ return { error: true, pos: i, message: `unexpected character "${c}"` };
63
+ }
64
+ return tokens;
65
+ }
66
+
67
+ // A standard recursive-descent parser: parseOr calls parseAnd repeatedly
68
+ // across OR tokens; parseAnd calls parseAtom repeatedly across explicit AND
69
+ // tokens; parseAtom handles `(expr)`, `field OP value-or-list`, and bare
70
+ // TEXT terms.
71
+ export function parseQuery(input) {
72
+ if (input.trim() === '') return { ast: null };
73
+ const tokenResult = tokenize(input);
74
+ if (!Array.isArray(tokenResult)) return { error: { message: tokenResult.message, pos: tokenResult.pos } };
75
+ const tokens = tokenResult;
76
+ let pos = 0;
77
+
78
+ function peek() { return tokens[pos]; }
79
+ function advance() { return tokens[pos++]; }
80
+ function fail(message) {
81
+ const t = peek();
82
+ throw { message, pos: t ? t.pos : input.length };
83
+ }
84
+
85
+ function parseOr() {
86
+ let node = parseAnd();
87
+ while (peek()?.type === 'OR') {
88
+ advance();
89
+ node = { type: 'OR', left: node, right: parseAnd() };
90
+ }
91
+ return node;
92
+ }
93
+
94
+ function parseAnd() {
95
+ let node = parseAtom();
96
+ while (peek()?.type === 'AND') {
97
+ advance();
98
+ node = { type: 'AND', left: node, right: parseAtom() };
99
+ }
100
+ return node;
101
+ }
102
+
103
+ function parseAtom() {
104
+ const t = peek();
105
+ if (!t) fail('unexpected end of query');
106
+ if (t.type === 'LPAREN') {
107
+ advance();
108
+ const inner = parseOr();
109
+ if (peek()?.type !== 'RPAREN') fail('expected closing ")"');
110
+ advance();
111
+ return inner;
112
+ }
113
+ if (t.type === 'FIELD') {
114
+ advance();
115
+ const opToken = advance();
116
+ if (!opToken || opToken.type !== 'OP') fail(`expected ":" or "!=" after field "${t.value}"`);
117
+ const values = parseValueList();
118
+ return { type: 'COMPARISON', field: t.value, op: opToken.value, values };
119
+ }
120
+ if (t.type === 'VALUE') {
121
+ advance();
122
+ return { type: 'TEXT', value: t.value };
123
+ }
124
+ fail(`unexpected token "${t.value}"`);
125
+ }
126
+
127
+ function parseValueList() {
128
+ if (peek()?.type === 'LPAREN') {
129
+ advance();
130
+ const values = [];
131
+ values.push(expectValue());
132
+ while (peek()?.type === 'COMMA') { advance(); values.push(expectValue()); }
133
+ if (peek()?.type !== 'RPAREN') fail('expected closing ")" in value list');
134
+ advance();
135
+ return values;
136
+ }
137
+ return [expectValue()];
138
+ }
139
+
140
+ function expectValue() {
141
+ const t = peek();
142
+ if (!t || t.type !== 'VALUE') fail('expected a value');
143
+ advance();
144
+ return t.value;
145
+ }
146
+
147
+ try {
148
+ const ast = parseOr();
149
+ if (pos < tokens.length) fail(`unexpected trailing token "${peek().value}"`);
150
+ return { ast };
151
+ } catch (e) {
152
+ if (e && typeof e.message === 'string') return { error: e };
153
+ throw e; // a genuine bug, not a user-facing parse error — do not swallow
154
+ }
155
+ }
156
+
157
+ // AST -> predicate. `compileQuery(ast, graph) -> (flow) -> boolean`. The
158
+ // predicate operates on a FLOW (matching how filtering already works
159
+ // throughout this codebase — Privacy/Inventory both filter at the flow
160
+ // level), with field accessors reaching into the flow's own source/sink
161
+ // nodes, data element, and edges as needed.
162
+
163
+ // Field name -> accessor function: (graph, flow) -> string[] (the set of
164
+ // real values that field name means for THIS flow — a comparison
165
+ // matches if ANY of a COMPARISON's own `values` appears in this set).
166
+ // Grounded in the parent scoping doc's own real-vs-inert audit — every
167
+ // accessor here reads a field CONFIRMED populated by real scan code.
168
+ const FIELD_ACCESSORS = {
169
+ class: (graph, flow) => {
170
+ const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
171
+ return de?.dataClasses ?? [];
172
+ },
173
+ field: (graph, flow) => {
174
+ const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
175
+ return de?.name ? [de.name] : [];
176
+ },
177
+ sink: (graph, flow) => {
178
+ const sinkNode = graph.nodes.find((n) => n.id === flow.sink);
179
+ return sinkNode?.subtype ? [sinkNode.subtype] : [];
180
+ },
181
+ source: (graph, flow) => {
182
+ const sourceNode = graph.nodes.find((n) => n.id === flow.source);
183
+ return sourceNode?.subtype ? [sourceNode.subtype] : [];
184
+ },
185
+ 'transit.verdict': (graph, flow) => edgeVerdicts(graph, flow, 'transit'),
186
+ 'at_rest.verdict': (graph, flow) => edgeVerdicts(graph, flow, 'atRest'),
187
+ 'handling.verdict': (graph, flow) => edgeVerdicts(graph, flow, 'handling'),
188
+ policy: (graph, flow) => (flow.policyVerdict ? [flow.policyVerdict] : []),
189
+ coverage: (graph, flow) => {
190
+ const pathNodeIds = flowPathNodeIds(graph, flow);
191
+ return graph.nodes.filter((n) => pathNodeIds.has(n.id)).map((n) => n.coverageStatus);
192
+ },
193
+ ai: (graph, flow) => [String(isAiRelevantFlow(graph, flow))],
194
+ 'destination.external': (graph, flow) => {
195
+ const pathNodeIds = flowPathNodeIds(graph, flow);
196
+ const anyExternal = graph.nodes.some((n) => pathNodeIds.has(n.id) && n.externality?.value === 'external');
197
+ return [String(anyExternal)];
198
+ },
199
+ };
200
+
201
+ function edgeVerdicts(graph, flow, dimension) {
202
+ return flow.edgeIds
203
+ .map((id) => graph.edges.find((e) => e.id === id))
204
+ .filter(Boolean)
205
+ .map((e) => e.protection[dimension].verdict);
206
+ }
207
+
208
+ export function compileQuery(ast, graph) {
209
+ if (ast === null || ast === undefined) return () => true;
210
+ return (flow) => evaluateNode(ast, graph, flow);
211
+ }
212
+
213
+ function evaluateNode(node, graph, flow) {
214
+ if (node.type === 'AND') return evaluateNode(node.left, graph, flow) && evaluateNode(node.right, graph, flow);
215
+ if (node.type === 'OR') return evaluateNode(node.left, graph, flow) || evaluateNode(node.right, graph, flow);
216
+ if (node.type === 'TEXT') return matchesText(graph, flow, node.value);
217
+ if (node.type === 'COMPARISON') {
218
+ const accessor = FIELD_ACCESSORS[node.field];
219
+ if (!accessor) {
220
+ // Real, reported condition (Step 1's own deliberately-open test) —
221
+ // throwing here, caught by compileQuery's own caller (Task 4's
222
+ // query bar), is the chosen mechanism: it surfaces as a real error
223
+ // to the user rather than silently matching nothing. Document
224
+ // this choice at the call site too.
225
+ throw new Error(`unrecognized query field "${node.field}"`);
226
+ }
227
+ const realValues = accessor(graph, flow).map((v) => String(v).toLowerCase());
228
+ const matches = node.values.some((v) => realValues.includes(v.toLowerCase()));
229
+ return node.op === '!=' ? !matches : matches;
230
+ }
231
+ throw new Error(`unknown AST node type "${node.type}"`);
232
+ }
233
+
234
+ function matchesText(graph, flow, term) {
235
+ const lower = term.toLowerCase();
236
+ const sourceLabel = graph.nodes.find((n) => n.id === flow.source)?.label ?? '';
237
+ const sinkLabel = graph.nodes.find((n) => n.id === flow.sink)?.label ?? '';
238
+ const de = graph.dataElements.find((d) => flow.dataElementIds.includes(d.id));
239
+ return [sourceLabel, sinkLabel, de?.name ?? ''].some((s) => s.toLowerCase().includes(lower));
240
+ }
@@ -0,0 +1,43 @@
1
+ // Shared, deduplicated row-vs-active-filters matcher — was previously two
2
+ // near-identical private copies (privacy-view.js's and inventory-view.js's
3
+ // own rowMatchesFilters). Every check here reads a PRE-ATTACHED row
4
+ // property (never the graph directly) — the caller's own row-computation
5
+ // step is responsible for attaching whichever of these properties make
6
+ // sense for that row's own shape (see lib/filter-rail.js's own facet list
7
+ // and each view's own row-building code). A facet whose property the row
8
+ // does not carry AT ALL is skipped (never a hide) — this is what makes it
9
+ // safe for a single shared function to serve row shapes as different as a
10
+ // Privacy flow-row and an Inventory dataElement-row.
11
+ const LIST_FACETS = [
12
+ ['dataClass', 'dataClasses', true], // true = row property is itself an array (dataClasses), match if ANY overlaps
13
+ ['protection', 'protectionSummary', false], // false = row property is a single value, match if included in the filter's list
14
+ ['transitVerdict', 'transitVerdict', false],
15
+ ['atRestVerdict', 'atRestVerdict', false],
16
+ ['handlingVerdict', 'handlingVerdict', false],
17
+ ['sourceCategory', 'sourceCategory', false],
18
+ ['sinkCategory', 'sinkCategory', false],
19
+ ['destinationExternality', 'destinationExternality', false],
20
+ ['policyVerdict', 'policyVerdict', false],
21
+ ];
22
+
23
+ export function matchesFilters(row, filters) {
24
+ for (const [filterKey, rowProp, rowIsArray] of LIST_FACETS) {
25
+ const activeValues = filters[filterKey];
26
+ if (!activeValues?.length) continue; // this facet isn't active at all
27
+ if (!(rowProp in row)) continue; // row doesn't carry this property — unaffected, not hidden
28
+ if (rowIsArray) {
29
+ if (!(row[rowProp] ?? []).some((v) => activeValues.includes(v))) return false;
30
+ } else {
31
+ if (row[rowProp] !== undefined && !activeValues.includes(row[rowProp])) return false;
32
+ }
33
+ }
34
+ // Unlike the 9 facets above (list-of-selected-values), `ai` is a single
35
+ // boolean toggle, matching its existing shape in both views — kept as
36
+ // its own explicit check, not folded into LIST_FACETS's generic loop,
37
+ // since it's structurally different (a boolean flag, not a multi-select).
38
+ // Checked for ANY row carrying `isAiRelevant` — this is the real fix for
39
+ // Inventory's own previously-missing AI check (its private
40
+ // rowMatchesFilters never checked `ai` at all; Privacy's did).
41
+ if (filters.ai && 'isAiRelevant' in row && !row.isAiRelevant) return false;
42
+ return true;
43
+ }
@@ -0,0 +1,84 @@
1
+ // Shared cross-view selection/filter state, persisted in the URL hash so
2
+ // switching views preserves the selected canonical ID and filters (AC-16),
3
+ // and so state is shareable/bookmarkable without a server. Per PRD §7.11:
4
+ // "URL state contains canonical IDs and non-sensitive filter expressions,
5
+ // never source snippets, field values, or secret-bearing endpoints" — this
6
+ // module only ever carries canonical IDs and filter keys/values the caller
7
+ // supplies, never arbitrary text.
8
+ //
9
+ // `state.filters` is an opaque, caller-defined object — this module never
10
+ // validates individual keys inside it, only that the blob as a whole is
11
+ // valid JSON. Milestone 3, sub-project M3-UX-Query adds `filters.query`
12
+ // (PRD §15.2's query-language string, a plain string) as one more key
13
+ // alongside the pre-existing `dataClass`/`protection`/`ai` keys — it
14
+ // round-trips through the hash for free via the same generic JSON
15
+ // serialize/parse path below, with no code change needed here beyond this
16
+ // note. Validating/parsing the query text itself is `lib/query-language.js`'s
17
+ // and `components/query-bar.js`'s job, not this module's — exactly like
18
+ // `dataClass`/`protection`/`ai` are validated by their own consumers
19
+ // (filter-rail.js, privacy-view.js, inventory-view.js), never here.
20
+
21
+ const VALID_VIEWS = new Set(['architecture', 'privacy', 'trace', 'inventory']);
22
+
23
+ // PRD §7.6's own 11 required inventory categories, in the order that
24
+ // section lists them. The canonical id list — inventory-view.js's
25
+ // per-category compute dispatch and its sub-nav strip both key off this
26
+ // exact array, so a rename here must update both call sites too.
27
+ export const INVENTORY_TABLES = Object.freeze([
28
+ 'sources',
29
+ 'sinks',
30
+ 'fields',
31
+ 'externalDestinations',
32
+ 'stores',
33
+ 'aiSystems',
34
+ 'transformations',
35
+ 'unprotectedEdges',
36
+ 'policyPermittedFlows',
37
+ 'manualGovernanceGaps',
38
+ 'unsupportedCandidates',
39
+ ]);
40
+
41
+ const DEFAULT_STATE = Object.freeze({ view: 'architecture', selectedId: null, filters: {}, table: INVENTORY_TABLES[0] });
42
+
43
+ export function parseStateFromHash(hash) {
44
+ const raw = String(hash ?? '').replace(/^#/, '');
45
+ if (!raw) return { ...DEFAULT_STATE, filters: {} };
46
+
47
+ let params;
48
+ try {
49
+ params = new URLSearchParams(raw);
50
+ } catch {
51
+ return { ...DEFAULT_STATE, filters: {} };
52
+ }
53
+
54
+ const view = params.get('view');
55
+ const selectedId = params.get('selected');
56
+ const filtersRaw = params.get('filters');
57
+ const tableRaw = params.get('table');
58
+
59
+ let filters = {};
60
+ if (filtersRaw) {
61
+ try {
62
+ const parsed = JSON.parse(filtersRaw);
63
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) filters = parsed;
64
+ } catch {
65
+ filters = {};
66
+ }
67
+ }
68
+
69
+ return {
70
+ view: VALID_VIEWS.has(view) ? view : DEFAULT_STATE.view,
71
+ selectedId: selectedId || null,
72
+ filters,
73
+ table: INVENTORY_TABLES.includes(tableRaw) ? tableRaw : DEFAULT_STATE.table,
74
+ };
75
+ }
76
+
77
+ export function serializeStateToHash(state) {
78
+ const params = new URLSearchParams();
79
+ params.set('view', VALID_VIEWS.has(state.view) ? state.view : DEFAULT_STATE.view);
80
+ if (state.selectedId) params.set('selected', state.selectedId);
81
+ if (state.filters && Object.keys(state.filters).length > 0) params.set('filters', JSON.stringify(state.filters));
82
+ if (state.table && state.table !== DEFAULT_STATE.table) params.set('table', state.table);
83
+ return `#${params.toString()}`;
84
+ }
@@ -0,0 +1,83 @@
1
+ // main.js — Milestone 3, sub-project Wire.
2
+ //
3
+ // The page's real entry point, external to index.html for one reason:
4
+ // index.html's own inline <script type="module"> block would need
5
+ // `script-src 'unsafe-inline'` under the explore server's static-asset CSP
6
+ // (scanner/src/server/static-assets.js's STATIC_CSP_HEADER_VALUE). Moving
7
+ // this logic into its own file, loaded via
8
+ // `<script type="module" src="./src/main.js">`, lets that CSP stay a
9
+ // strict `script-src 'self'` with no exception — see the M3-Wire plan's own
10
+ // "load-bearing correction" on this point.
11
+ //
12
+ // Replaces the old static import of FLAGSHIP_GRAPH with a live fetch
13
+ // against the explore server's own /api/v1/graph endpoint, authenticated
14
+ // via the token carried in the page's own URL fragment. bootstrap() itself
15
+ // is UNCHANGED — it already takes a plain graph object with no assumption
16
+ // about where it came from.
17
+
18
+ import { bootstrap } from './app.js';
19
+ import { el, clear } from './lib/dom.js';
20
+ import { extractTokenFromFragment, fetchGraph } from './lib/api-client.js';
21
+
22
+ // Exported for test/golden-state-matrix.test.js (AC-22's "Error" state row)
23
+ // only. This is a disclosed, deliberate choice, not an oversight: Node's
24
+ // stable `node:test` in this repo's Node version (confirmed this session —
25
+ // `node:test`'s `mock` object has no `.module` method here) has no ESM
26
+ // module-mocking primitive, so there is no way to make main.js's real
27
+ // `init()`/fetch-catch path exercise a REAL failing `fetchGraph` without
28
+ // either (a) a network-mocking dependency this repo doesn't otherwise carry,
29
+ // or (b) exporting the real render function and calling it directly with the
30
+ // same message shape `init()`'s own catch block already builds. (b) is the
31
+ // smaller footprint — it adds one keyword to an existing function instead of
32
+ // a parallel test-only reimplementation or a new dependency, and it matches
33
+ // this sub-project's own golden-DOM pattern elsewhere (golden-architecture.
34
+ // test.js / golden-privacy.test.js call the real compute/render functions
35
+ // directly rather than a test double). The exported symbol is the exact
36
+ // function `init()` calls on a fetch failure — no test-only duplicate logic.
37
+ export function showError(rootEl, message) {
38
+ clear(rootEl);
39
+ rootEl.appendChild(
40
+ el('div', { class: 'app-error' }, [
41
+ el('div', { class: 'app-error__title' }, 'Data Flow Explorer could not load'),
42
+ el('div', { class: 'app-error__message' }, message),
43
+ ]),
44
+ );
45
+ }
46
+
47
+ async function init() {
48
+ const rootEl = document.getElementById('app-root');
49
+
50
+ const token = extractTokenFromFragment();
51
+ if (!token) {
52
+ showError(
53
+ rootEl,
54
+ 'No session token found in this page’s URL. Start a new session with `agentic-security explore` and open the URL it prints (including the "#token=..." part).',
55
+ );
56
+ return;
57
+ }
58
+
59
+ let graph;
60
+ try {
61
+ graph = await fetchGraph({ token });
62
+ } catch (err) {
63
+ showError(rootEl, `Failed to load the data flow graph: ${err && err.message ? err.message : String(err)}`);
64
+ return;
65
+ }
66
+
67
+ bootstrap(rootEl, graph);
68
+ }
69
+
70
+ // Guarded on the mount point actually existing before auto-invoking, rather
71
+ // than calling init() unconditionally: this file's ONLY export
72
+ // (`showError`, above) is imported directly by test/golden-state-matrix.
73
+ // test.js, and importing an ES module always runs its top-level statements
74
+ // — including this one — as a side effect. Without this guard, importing
75
+ // main.js under test/dom-shim.js's minimal document (no `#app-root` element
76
+ // exists, since dom-shim builds a bare virtual tree, not a full page)
77
+ // crashed with an unhandled rejection from `document.getElementById`
78
+ // returning something the rest of init() can't use. The guard is also a
79
+ // real, sensible defensive property outside of tests — this script is never
80
+ // meant to run against a page lacking its own mount point.
81
+ if (typeof document !== 'undefined' && typeof document.getElementById === 'function' && document.getElementById('app-root')) {
82
+ init();
83
+ }
@@ -0,0 +1,184 @@
1
+ import { el, clear } from './lib/dom.js';
2
+ import { parseStateFromHash, serializeStateToHash } from './lib/state.js';
3
+
4
+ const VIEWS = [
5
+ { id: 'architecture', label: 'Architecture' },
6
+ { id: 'privacy', label: 'Privacy' },
7
+ { id: 'trace', label: 'Trace' },
8
+ { id: 'inventory', label: 'Inventory' },
9
+ ];
10
+
11
+ /**
12
+ * @param {HTMLElement} rootEl
13
+ * @param {object} graph - a DataFlowGraph v1 envelope (already validated at build time)
14
+ * @returns {{
15
+ * setActiveView: (viewName: string) => void,
16
+ * getState: () => {view: string, selectedId: string|null, filters: object},
17
+ * setSelection: (selectedId: string|null) => void,
18
+ * setFilters: (filters: object) => void,
19
+ * setTable: (tableId: string) => void,
20
+ * onStateChange: (listener: (state: object) => void) => (() => void),
21
+ * getCanvasEl: () => HTMLElement,
22
+ * getInspectorEl: () => HTMLElement,
23
+ * getContextRailEl: () => HTMLElement,
24
+ * getLeftRailEl: () => HTMLElement,
25
+ * getQueryBarEl: () => HTMLElement,
26
+ * destroy: () => void,
27
+ * }}
28
+ */
29
+ export function mountShell(rootEl, graph) {
30
+ let state = parseStateFromHash(window.location.hash);
31
+ let stateChangeListeners = [];
32
+ let inspectorOverlayOpen = false;
33
+
34
+ const shell = el('div', { class: 'shell' });
35
+ const header = buildHeader(graph);
36
+ const coverageBanner = buildCoverageBanner(graph);
37
+ const tabs = buildViewTabs(state.view, (nextView) => {
38
+ updateState({ ...state, view: nextView });
39
+ });
40
+ const leftRail = el('div', { class: 'shell__left-rail' }, 'Filters (wired by the next plan)');
41
+ const canvas = el('div', { class: 'shell__canvas' });
42
+ const inspector = el('div', { class: 'shell__inspector', id: 'shell-inspector' }, 'Evidence inspector (wired by the next plan)');
43
+ const contextRail = el('div', { class: 'shell__context-rail' }, buildContextRailText(graph));
44
+
45
+ const inspectorToggle = el(
46
+ 'button',
47
+ {
48
+ class: 'shell__inspector-toggle',
49
+ 'aria-expanded': 'false',
50
+ 'aria-controls': 'shell-inspector',
51
+ onClick: () => {
52
+ inspectorOverlayOpen = !inspectorOverlayOpen;
53
+ inspector.setAttribute('data-overlay-open', String(inspectorOverlayOpen));
54
+ inspectorToggle.setAttribute('aria-expanded', String(inspectorOverlayOpen));
55
+ },
56
+ },
57
+ 'Inspector',
58
+ );
59
+ tabs.appendChild(inspectorToggle);
60
+
61
+ // Milestone 3, sub-project M3-UX-Query, Task 4: the query bar's own real
62
+ // slot. Placed into the existing view-tabs row (shell__view-tabs) rather
63
+ // than a new grid region, matching this session's own A11y precedent of
64
+ // adding a new control into an existing shell row (inspectorToggle,
65
+ // directly above) instead of widening shell.css's grid-template-areas.
66
+ const queryBar = el('div', { class: 'shell__query-bar-slot' });
67
+ tabs.appendChild(queryBar);
68
+
69
+ shell.appendChild(header);
70
+ shell.appendChild(coverageBanner);
71
+ shell.appendChild(tabs);
72
+ shell.appendChild(leftRail);
73
+ shell.appendChild(canvas);
74
+ shell.appendChild(inspector);
75
+ shell.appendChild(contextRail);
76
+
77
+ clear(rootEl);
78
+ rootEl.appendChild(shell);
79
+
80
+ function notifyStateChange() {
81
+ const snapshot = { ...state };
82
+ for (const listener of stateChangeListeners) listener(snapshot);
83
+ }
84
+
85
+ // Shared by setActiveView/setSelection/setFilters/the tab-click handler:
86
+ // update the closure state, sync the URL hash, refresh the tab UI, notify.
87
+ function updateState(nextState) {
88
+ state = nextState;
89
+ window.location.hash = serializeStateToHash(state);
90
+ applyActiveTab(tabs, state.view);
91
+ notifyStateChange();
92
+ }
93
+
94
+ function handleHashChange() {
95
+ state = parseStateFromHash(window.location.hash);
96
+ applyActiveTab(tabs, state.view);
97
+ notifyStateChange();
98
+ }
99
+
100
+ window.addEventListener('hashchange', handleHashChange);
101
+
102
+ return {
103
+ setActiveView(viewName) {
104
+ updateState({ ...state, view: viewName });
105
+ },
106
+ getState() {
107
+ return { ...state };
108
+ },
109
+ setSelection(selectedId) {
110
+ updateState({ ...state, selectedId });
111
+ },
112
+ setFilters(filters) {
113
+ updateState({ ...state, filters });
114
+ },
115
+ setTable(tableId) {
116
+ updateState({ ...state, table: tableId });
117
+ },
118
+ onStateChange(listener) {
119
+ stateChangeListeners.push(listener);
120
+ return () => {
121
+ stateChangeListeners = stateChangeListeners.filter((l) => l !== listener);
122
+ };
123
+ },
124
+ getCanvasEl: () => canvas,
125
+ getInspectorEl: () => inspector,
126
+ getContextRailEl: () => contextRail,
127
+ getLeftRailEl: () => leftRail,
128
+ getQueryBarEl: () => queryBar,
129
+ destroy() {
130
+ window.removeEventListener('hashchange', handleHashChange);
131
+ stateChangeListeners = [];
132
+ },
133
+ };
134
+ }
135
+
136
+ function buildHeader(graph) {
137
+ const repo = graph.scope?.repository ?? 'unknown repository';
138
+ const env = graph.scope?.environment ?? 'unknown environment';
139
+ const scanStatus = graph.scanHealth?.status ?? 'unknown';
140
+ const isFixture = graph.scope?.source === 'fixture';
141
+ return el('div', { class: 'shell__header' }, [
142
+ el('div', { class: 'shell__header-title' }, 'Data Flow Explorer'),
143
+ el('div', { class: 'shell__header-meta' }, `${repo} · ${env} · Scan ${scanStatus}`),
144
+ isFixture ? el('div', { class: 'shell__header-meta', 'data-illustrative': 'true' }, 'Illustrative demo data') : null,
145
+ ]);
146
+ }
147
+
148
+ function buildCoverageBanner(graph) {
149
+ const status = graph.coverage?.status ?? graph.scanHealth?.status;
150
+ const banner = el('div', { class: 'shell__coverage-banner' }, `Coverage: ${status ?? 'unknown'} — not a complete assessment`);
151
+ if (status && status !== 'complete') banner.setAttribute('data-visible', 'true');
152
+ return banner;
153
+ }
154
+
155
+ function buildViewTabs(activeView, onSelect) {
156
+ const tabs = el(
157
+ 'div',
158
+ { class: 'shell__view-tabs', role: 'tablist' },
159
+ VIEWS.map((v) =>
160
+ el(
161
+ 'button',
162
+ {
163
+ class: 'shell__view-tab',
164
+ role: 'tab',
165
+ 'aria-selected': String(v.id === activeView),
166
+ 'data-view-id': v.id,
167
+ onClick: () => onSelect(v.id),
168
+ },
169
+ v.label,
170
+ ),
171
+ ),
172
+ );
173
+ return tabs;
174
+ }
175
+
176
+ function applyActiveTab(tabsEl, activeView) {
177
+ for (const btn of tabsEl.querySelectorAll('[data-view-id]')) {
178
+ btn.setAttribute('aria-selected', String(btn.getAttribute('data-view-id') === activeView));
179
+ }
180
+ }
181
+
182
+ export function buildContextRailText(graph) {
183
+ return `${graph.nodes.length} nodes · ${graph.edges.length} edges · ${graph.flows.length} flows`;
184
+ }