n8n-mcp-community 0.1.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 (59) hide show
  1. package/CHANGELOG.md +116 -0
  2. package/CODE_OF_CONDUCT.md +51 -0
  3. package/CONTRIBUTING.md +69 -0
  4. package/LICENSE +21 -0
  5. package/README.md +271 -0
  6. package/ROADMAP.md +41 -0
  7. package/SECURITY.md +53 -0
  8. package/SUPPORT.md +19 -0
  9. package/THIRD_PARTY_NOTICES.md +48 -0
  10. package/dist/config.js +76 -0
  11. package/dist/content/node-docs.js +47 -0
  12. package/dist/content/official-urls.js +11 -0
  13. package/dist/doctor.js +88 -0
  14. package/dist/index.js +119 -0
  15. package/dist/introspect/collector.js +642 -0
  16. package/dist/introspect/contracts.js +309 -0
  17. package/dist/introspect/engine.js +184 -0
  18. package/dist/introspect/http-client.js +142 -0
  19. package/dist/introspect/index.js +5 -0
  20. package/dist/introspect/order.js +3 -0
  21. package/dist/introspect/render.js +25 -0
  22. package/dist/introspect/rule-ids.js +25 -0
  23. package/dist/introspect/rules.js +905 -0
  24. package/dist/introspect/sanitize.js +66 -0
  25. package/dist/n8n/client.js +170 -0
  26. package/dist/prompts.js +69 -0
  27. package/dist/resources.js +42 -0
  28. package/dist/security/operation-policy.js +20 -0
  29. package/dist/security/redaction.js +253 -0
  30. package/dist/server.js +16 -0
  31. package/dist/tools/common.js +54 -0
  32. package/dist/tools/credentials.js +259 -0
  33. package/dist/tools/definition.js +169 -0
  34. package/dist/tools/endpoint-contracts.js +53 -0
  35. package/dist/tools/executions.js +180 -0
  36. package/dist/tools/registry.js +24 -0
  37. package/dist/tools/schemas.js +203 -0
  38. package/dist/tools/tags.js +73 -0
  39. package/dist/tools/users.js +130 -0
  40. package/dist/tools/utilities.js +347 -0
  41. package/dist/tools/workflows.js +771 -0
  42. package/dist/version.js +1 -0
  43. package/docs/README.md +21 -0
  44. package/docs/architecture.md +122 -0
  45. package/docs/assets/demo.gif +0 -0
  46. package/docs/clients.md +95 -0
  47. package/docs/compatibility.md +112 -0
  48. package/docs/configuration.md +87 -0
  49. package/docs/demo-transcript.md +31 -0
  50. package/docs/examples.md +86 -0
  51. package/docs/faq.md +90 -0
  52. package/docs/getting-started.md +93 -0
  53. package/docs/installation.md +120 -0
  54. package/docs/provenance.md +152 -0
  55. package/docs/security-model.md +170 -0
  56. package/docs/tools.md +937 -0
  57. package/docs/troubleshooting.md +185 -0
  58. package/package.json +126 -0
  59. package/server.json +51 -0
@@ -0,0 +1,309 @@
1
+ import { z } from "zod";
2
+ import { INTROSPECT_RULE_IDS } from "./rule-ids.js";
3
+ export const INTROSPECT_SCHEMA_VERSION = "1.0.0";
4
+ export const INTROSPECT_ENGINE_VERSION = "2.0.0";
5
+ export const INTROSPECT_ERROR_CODES = Object.freeze([
6
+ "invalid_input",
7
+ "invalid_output",
8
+ "invalid_path",
9
+ "deadline_exceeded",
10
+ "response_too_large",
11
+ "upstream_http_error",
12
+ "invalid_json",
13
+ "invalid_schema",
14
+ ]);
15
+ export const INTROSPECT_LIMITATION_CODES = Object.freeze([
16
+ "deadline_exceeded",
17
+ "response_too_large",
18
+ "upstream_http_error",
19
+ "invalid_json",
20
+ "invalid_schema",
21
+ "repeated_cursor",
22
+ "invalid_timestamp",
23
+ "invalid_execution_id",
24
+ "page_failed",
25
+ "detail_failed",
26
+ "ordering_unreliable",
27
+ "finding_limit",
28
+ "output_limit",
29
+ "label_redaction_limit",
30
+ ]);
31
+ const WorkflowIdSchema = z
32
+ .string()
33
+ .min(1)
34
+ .max(128)
35
+ .regex(/^[A-Za-z0-9_-]+$/)
36
+ .describe("n8n workflow ID. Accepts only ASCII letters, digits, underscore, and hyphen.");
37
+ export const IntrospectInputSchema = z
38
+ .object({
39
+ workflowId: WorkflowIdSchema,
40
+ profile: z
41
+ .enum(["quick", "deep"])
42
+ .default("quick")
43
+ .describe("Use quick for metadata-only analysis or deep for up to four redacted execution details."),
44
+ lookbackHours: z
45
+ .number()
46
+ .int()
47
+ .min(1)
48
+ .max(720)
49
+ .optional()
50
+ .describe("History window in hours. Defaults to 24 for quick and 168 for deep."),
51
+ maxExecutions: z
52
+ .number()
53
+ .int()
54
+ .min(1)
55
+ .max(100)
56
+ .optional()
57
+ .describe("Maximum saved executions to inspect. Defaults to 20 for quick and 50 for deep; quick is capped at 25."),
58
+ includeSanitizedLabels: z
59
+ .boolean()
60
+ .default(false)
61
+ .describe("Include bounded pattern-sanitized workflow and node labels. Opaque identifiers are returned by default."),
62
+ })
63
+ .strict()
64
+ .superRefine((input, context) => {
65
+ if (input.profile === "quick" &&
66
+ input.maxExecutions !== undefined &&
67
+ input.maxExecutions > 25) {
68
+ context.addIssue({
69
+ code: z.ZodIssueCode.too_big,
70
+ maximum: 25,
71
+ inclusive: true,
72
+ type: "number",
73
+ path: ["maxExecutions"],
74
+ message: "Quick profile supports at most 25 executions",
75
+ });
76
+ }
77
+ });
78
+ export function resolveIntrospectInput(input) {
79
+ return {
80
+ workflowId: input.workflowId,
81
+ profile: input.profile,
82
+ lookbackHours: input.lookbackHours ?? (input.profile === "quick" ? 24 : 168),
83
+ maxExecutions: input.maxExecutions ?? (input.profile === "quick" ? 20 : 50),
84
+ includeSanitizedLabels: input.includeSanitizedLabels,
85
+ };
86
+ }
87
+ export const WorkflowNodeSchema = z
88
+ .object({
89
+ id: z.string().optional(),
90
+ name: z.string(),
91
+ type: z.string().optional(),
92
+ typeVersion: z.number().finite().min(-10_000).max(10_000).optional(),
93
+ disabled: z.boolean().optional(),
94
+ retryOnFail: z.boolean().optional(),
95
+ maxTries: z.number().optional(),
96
+ waitBetweenTries: z.number().optional(),
97
+ continueOnFail: z.boolean().optional(),
98
+ onError: z.string().max(128).optional(),
99
+ executeOnce: z.boolean().optional(),
100
+ alwaysOutputData: z.boolean().optional(),
101
+ parameters: z.record(z.unknown()).optional(),
102
+ credentials: z.record(z.unknown()).optional(),
103
+ })
104
+ .passthrough();
105
+ export const WorkflowResponseSchema = z
106
+ .object({
107
+ id: z.string(),
108
+ name: z.string(),
109
+ active: z.boolean(),
110
+ triggerCount: z.number().int().nonnegative().optional(),
111
+ nodes: z.array(WorkflowNodeSchema).max(1_000).default([]),
112
+ connections: z.record(z.unknown()).default({}),
113
+ settings: z.record(z.unknown()).default({}),
114
+ pinData: z.record(z.unknown()).nullable().optional(),
115
+ })
116
+ .passthrough();
117
+ const ApiIdentifierSchema = z.union([z.string(), z.number()]).transform(String);
118
+ const ExecutionCursorSchema = z
119
+ .string()
120
+ .min(1)
121
+ .max(2_048)
122
+ .refine((value) => !/[\u0000-\u001f\u007f]/.test(value), "Execution cursors must not contain control characters.");
123
+ export const ExecutionMetadataSchema = z
124
+ .object({
125
+ id: ApiIdentifierSchema,
126
+ status: z.string(),
127
+ mode: z.string().optional(),
128
+ startedAt: z.string().nullable().optional(),
129
+ stoppedAt: z.string().nullable().optional(),
130
+ waitTill: z.string().nullable().optional(),
131
+ workflowId: ApiIdentifierSchema.nullable().optional(),
132
+ })
133
+ .passthrough();
134
+ export const ExecutionListResponseSchema = z
135
+ .object({
136
+ data: z.array(ExecutionMetadataSchema).max(100).default([]),
137
+ nextCursor: ExecutionCursorSchema.nullable().optional(),
138
+ })
139
+ .passthrough();
140
+ export const ExecutionDetailResponseSchema = ExecutionMetadataSchema.extend({
141
+ data: z.record(z.unknown()).optional(),
142
+ }).passthrough();
143
+ export const SeveritySchema = z.enum(["critical", "high", "medium", "low", "info"]);
144
+ export const ConfidenceSchema = z.enum(["high", "medium", "low"]);
145
+ export const CategorySchema = z.enum([
146
+ "structure",
147
+ "contract",
148
+ "reliability",
149
+ "performance",
150
+ "privacy",
151
+ "maintainability",
152
+ "observability",
153
+ ]);
154
+ const FactValueSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
155
+ const MAX_FINDING_ENTITY_KEY_LENGTH = 128;
156
+ const MAX_FINDING_ID_LENGTH = Math.max(...INTROSPECT_RULE_IDS.map((ruleId) => ruleId.length)) +
157
+ 1 +
158
+ MAX_FINDING_ENTITY_KEY_LENGTH;
159
+ export const FindingSchema = z.object({
160
+ id: z.string().max(MAX_FINDING_ID_LENGTH),
161
+ ruleId: z.enum(INTROSPECT_RULE_IDS),
162
+ category: CategorySchema,
163
+ severity: SeveritySchema,
164
+ confidence: ConfidenceSchema,
165
+ title: z.string(),
166
+ summary: z.string(),
167
+ affectedEntity: z.object({
168
+ kind: z.string(),
169
+ key: z.string().max(MAX_FINDING_ENTITY_KEY_LENGTH),
170
+ label: z.string().optional(),
171
+ }),
172
+ evidence: z.object({
173
+ summary: z.string(),
174
+ facts: z.record(FactValueSchema).optional(),
175
+ }),
176
+ remediation: z.string(),
177
+ documentationUrl: z
178
+ .string()
179
+ .max(2_048)
180
+ .refine((value) => {
181
+ try {
182
+ const protocol = new URL(value).protocol;
183
+ return protocol === "https:" || protocol === "http:";
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }, "Documentation URLs must use HTTP or HTTPS.")
189
+ .optional(),
190
+ });
191
+ export const RuleCoverageSchema = z.object({
192
+ ruleId: z.enum(INTROSPECT_RULE_IDS),
193
+ outcome: z.enum(["triggered", "passed", "not_applicable", "inconclusive"]),
194
+ partiallyInconclusive: z.boolean(),
195
+ reason: z.string(),
196
+ findingCount: z.number().int().nonnegative(),
197
+ totalFindingCount: z.number().int().nonnegative(),
198
+ omittedFindingCount: z.number().int().nonnegative(),
199
+ detectedCycleCount: z.number().int().nonnegative(),
200
+ inconclusiveCycleCount: z.number().int().nonnegative(),
201
+ });
202
+ const StatusCountsSchema = z.object({
203
+ success: z.number().int().nonnegative(),
204
+ error: z.number().int().nonnegative(),
205
+ crashed: z.number().int().nonnegative(),
206
+ canceled: z.number().int().nonnegative(),
207
+ running: z.number().int().nonnegative(),
208
+ waiting: z.number().int().nonnegative(),
209
+ new: z.number().int().nonnegative(),
210
+ unknown: z.number().int().nonnegative(),
211
+ });
212
+ const DurationMetricsSchema = z.object({
213
+ sampleCount: z.number().int().nonnegative(),
214
+ meanMs: z.number().nonnegative().nullable(),
215
+ minMs: z.number().nonnegative().nullable(),
216
+ maxMs: z.number().nonnegative().nullable(),
217
+ p50Ms: z.number().nonnegative().nullable(),
218
+ p95Ms: z.number().nonnegative().nullable(),
219
+ p99Ms: z.number().nonnegative().nullable(),
220
+ });
221
+ export const IntrospectResultSchema = z.object({
222
+ schemaVersion: z.literal(INTROSPECT_SCHEMA_VERSION),
223
+ engineVersion: z.literal(INTROSPECT_ENGINE_VERSION),
224
+ status: z.enum(["complete", "partial"]),
225
+ profile: z.enum(["quick", "deep"]),
226
+ workflow: z.object({
227
+ id: z.string(),
228
+ label: z.string().optional(),
229
+ active: z.boolean(),
230
+ nodeCount: z.number().int().nonnegative(),
231
+ }),
232
+ sample: z.object({
233
+ lookbackHours: z.number().int().positive(),
234
+ maxExecutions: z.number().int().positive(),
235
+ metadataExecutions: z.number().int().nonnegative(),
236
+ eligibleExecutions: z.number().int().nonnegative(),
237
+ pages: z.number().int().nonnegative(),
238
+ detailRequests: z.number().int().nonnegative(),
239
+ sampledErrors: z.number().int().nonnegative(),
240
+ acceptedBytes: z.number().int().nonnegative(),
241
+ ordering: z.enum(["verified_newest_first", "unreliable", "unknown"]),
242
+ historyBoundary: z.enum([
243
+ "complete",
244
+ "lookback_reached",
245
+ "request_limited",
246
+ "execution_limited",
247
+ "ordering_unreliable",
248
+ "unknown",
249
+ ]),
250
+ oldestStartedAt: z.string().nullable(),
251
+ newestStartedAt: z.string().nullable(),
252
+ }),
253
+ summary: z.object({
254
+ findingCounts: z.object({
255
+ critical: z.number().int().nonnegative(),
256
+ high: z.number().int().nonnegative(),
257
+ medium: z.number().int().nonnegative(),
258
+ low: z.number().int().nonnegative(),
259
+ info: z.number().int().nonnegative(),
260
+ }),
261
+ ruleOutcomes: z.object({
262
+ triggered: z.number().int().nonnegative(),
263
+ passed: z.number().int().nonnegative(),
264
+ notApplicable: z.number().int().nonnegative(),
265
+ inconclusive: z.number().int().nonnegative(),
266
+ partiallyInconclusive: z.number().int().nonnegative(),
267
+ }),
268
+ findings: z.object({
269
+ totalCount: z.number().int().nonnegative(),
270
+ retainedCount: z.number().int().nonnegative(),
271
+ omittedCount: z.number().int().nonnegative(),
272
+ truncated: z.boolean(),
273
+ }),
274
+ }),
275
+ metrics: z.object({
276
+ statusCounts: StatusCountsSchema,
277
+ successRate: z.number().min(0).max(1).nullable(),
278
+ duration: DurationMetricsSchema,
279
+ consecutiveErrors: z.number().int().nonnegative().nullable(),
280
+ perNodeTimings: z
281
+ .array(z.object({
282
+ nodeRef: z.string(),
283
+ label: z.string().optional(),
284
+ sampleCount: z.number().int().positive(),
285
+ medianMs: z.number().nonnegative(),
286
+ maxMs: z.number().nonnegative(),
287
+ }))
288
+ .max(1_000),
289
+ errorClusters: z
290
+ .array(z.object({
291
+ fingerprint: z.string().regex(/^[a-f0-9]{16}$/),
292
+ nodeRef: z.string().optional(),
293
+ label: z.string().optional(),
294
+ sampleCount: z.number().int().positive(),
295
+ sampledErrorDetails: z.number().int().nonnegative(),
296
+ sampledErrors: z.number().int().nonnegative(),
297
+ }))
298
+ .max(100),
299
+ }),
300
+ findings: z.array(FindingSchema).max(1_000),
301
+ ruleCoverage: z.array(RuleCoverageSchema).max(100),
302
+ limitations: z
303
+ .array(z.object({ code: z.enum(INTROSPECT_LIMITATION_CODES), message: z.string() }))
304
+ .max(100),
305
+ guidance: z.object({
306
+ instanceSecurityTool: z.literal("n8n_audit_generate"),
307
+ message: z.string(),
308
+ }),
309
+ });
@@ -0,0 +1,184 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { PROFILE_BUDGETS, collectSnapshot } from "./collector.js";
3
+ import { INTROSPECT_ENGINE_VERSION, INTROSPECT_SCHEMA_VERSION, IntrospectInputSchema, IntrospectResultSchema, resolveIntrospectInput, } from "./contracts.js";
4
+ import { computeMetrics, evaluateRules } from "./rules.js";
5
+ export class IntrospectInputError extends Error {
6
+ code = "invalid_input";
7
+ constructor(message = "The Introspect input is invalid.") {
8
+ super(message);
9
+ this.name = "IntrospectInputError";
10
+ }
11
+ }
12
+ export class IntrospectOutputError extends Error {
13
+ code = "invalid_output";
14
+ constructor(message = "The Introspect result could not be represented safely.") {
15
+ super(message);
16
+ this.name = "IntrospectOutputError";
17
+ }
18
+ }
19
+ function findingCounts(findings) {
20
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
21
+ findings.forEach((finding) => {
22
+ counts[finding.severity] += 1;
23
+ });
24
+ return counts;
25
+ }
26
+ function ruleOutcomeCounts(coverage) {
27
+ const counts = {
28
+ triggered: 0,
29
+ passed: 0,
30
+ notApplicable: 0,
31
+ inconclusive: 0,
32
+ partiallyInconclusive: 0,
33
+ };
34
+ coverage.forEach((record) => {
35
+ if (record.outcome === "not_applicable")
36
+ counts.notApplicable += 1;
37
+ else
38
+ counts[record.outcome] += 1;
39
+ if (record.partiallyInconclusive)
40
+ counts.partiallyInconclusive += 1;
41
+ });
42
+ return counts;
43
+ }
44
+ function findingDisclosure(coverage, retainedCount) {
45
+ const totalCount = coverage.reduce((sum, record) => sum + record.totalFindingCount, 0);
46
+ return {
47
+ totalCount,
48
+ retainedCount,
49
+ omittedCount: totalCount - retainedCount,
50
+ truncated: totalCount > retainedCount,
51
+ };
52
+ }
53
+ function synchronizeFindingDisclosure(result) {
54
+ const retainedByRule = new Map();
55
+ for (const finding of result.findings) {
56
+ retainedByRule.set(finding.ruleId, (retainedByRule.get(finding.ruleId) ?? 0) + 1);
57
+ }
58
+ for (const record of result.ruleCoverage) {
59
+ record.findingCount = retainedByRule.get(record.ruleId) ?? 0;
60
+ record.omittedFindingCount = record.totalFindingCount - record.findingCount;
61
+ }
62
+ result.summary.findingCounts = findingCounts(result.findings);
63
+ result.summary.findings = findingDisclosure(result.ruleCoverage, result.findings.length);
64
+ }
65
+ function jsonBytes(value) {
66
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
67
+ }
68
+ function addLimitation(limitations, code, message) {
69
+ if (!limitations.some((item) => item.code === code))
70
+ limitations.push({ code, message });
71
+ }
72
+ function retainLargestPrefix(items, fits) {
73
+ const original = [...items];
74
+ let low = 0;
75
+ let high = original.length;
76
+ let best = 0;
77
+ const setLength = (length) => {
78
+ items.length = 0;
79
+ for (const item of original.slice(0, length))
80
+ items.push(item);
81
+ };
82
+ while (low <= high) {
83
+ const middle = Math.floor((low + high) / 2);
84
+ setLength(middle);
85
+ if (fits()) {
86
+ best = middle;
87
+ low = middle + 1;
88
+ }
89
+ else {
90
+ high = middle - 1;
91
+ }
92
+ }
93
+ setLength(best);
94
+ }
95
+ export function fitStructuredResult(result) {
96
+ const limit = PROFILE_BUDGETS[result.profile].structuredOutputBytes;
97
+ if (jsonBytes(result) <= limit)
98
+ return result;
99
+ const reduced = structuredClone(result);
100
+ reduced.status = "partial";
101
+ addLimitation(reduced.limitations, "output_limit", "The report was reduced to remain within the immutable output byte limit.");
102
+ const synchronizedResultFits = () => {
103
+ synchronizeFindingDisclosure(reduced);
104
+ return jsonBytes(reduced) <= limit;
105
+ };
106
+ retainLargestPrefix(reduced.metrics.perNodeTimings, synchronizedResultFits);
107
+ if (!synchronizedResultFits()) {
108
+ retainLargestPrefix(reduced.metrics.errorClusters, synchronizedResultFits);
109
+ }
110
+ if (!synchronizedResultFits()) {
111
+ retainLargestPrefix(reduced.findings, synchronizedResultFits);
112
+ }
113
+ if (!synchronizedResultFits()) {
114
+ throw new IntrospectOutputError("The fixed Introspect contract exceeds its output budget.");
115
+ }
116
+ // A mismatch here is an internal engine invariant failure, never upstream n8n shape drift.
117
+ const fitted = IntrospectResultSchema.safeParse(reduced);
118
+ if (!fitted.success)
119
+ throw new IntrospectOutputError();
120
+ return fitted.data;
121
+ }
122
+ export async function inspectWorkflow(client, rawInput) {
123
+ const parsedInput = IntrospectInputSchema.safeParse(rawInput);
124
+ if (!parsedInput.success)
125
+ throw new IntrospectInputError();
126
+ const input = resolveIntrospectInput(parsedInput.data);
127
+ const snapshot = await collectSnapshot(client, input);
128
+ const evaluation = evaluateRules(snapshot);
129
+ const metrics = computeMetrics(snapshot);
130
+ const limitations = [];
131
+ snapshot.limitations.forEach((item) => addLimitation(limitations, item.code, item.message));
132
+ if (evaluation.truncatedRuleIds.length > 0) {
133
+ addLimitation(limitations, "finding_limit", "One or more rules produced more occurrences than the immutable output cap allows.");
134
+ }
135
+ if (input.includeSanitizedLabels) {
136
+ addLimitation(limitations, "label_redaction_limit", "Pattern redaction cannot recognize every sensitive proper name; opaque identifiers remain authoritative.");
137
+ }
138
+ // The assembled result is a local invariant: a mismatch is an internal output failure
139
+ // (invalid_output), never upstream n8n response-shape drift.
140
+ const assembled = IntrospectResultSchema.safeParse({
141
+ schemaVersion: INTROSPECT_SCHEMA_VERSION,
142
+ engineVersion: INTROSPECT_ENGINE_VERSION,
143
+ status: snapshot.status === "partial" || evaluation.truncatedRuleIds.length > 0
144
+ ? "partial"
145
+ : "complete",
146
+ profile: input.profile,
147
+ workflow: {
148
+ id: snapshot.workflow.id,
149
+ ...(snapshot.workflow.label ? { label: snapshot.workflow.label } : {}),
150
+ active: snapshot.workflow.active,
151
+ nodeCount: snapshot.workflow.nodes.length,
152
+ },
153
+ sample: {
154
+ lookbackHours: input.lookbackHours,
155
+ maxExecutions: input.maxExecutions,
156
+ metadataExecutions: snapshot.totalMetadataExecutions,
157
+ eligibleExecutions: metrics.statusCounts.success + metrics.statusCounts.error + metrics.statusCounts.crashed,
158
+ pages: snapshot.pages,
159
+ detailRequests: snapshot.detailRequests,
160
+ sampledErrors: snapshot.sampledErrors,
161
+ acceptedBytes: snapshot.acceptedBytes,
162
+ ordering: snapshot.ordering,
163
+ historyBoundary: snapshot.historyBoundary,
164
+ oldestStartedAt: snapshot.oldestStartedAt,
165
+ newestStartedAt: snapshot.newestStartedAt,
166
+ },
167
+ summary: {
168
+ findingCounts: findingCounts(evaluation.findings),
169
+ ruleOutcomes: ruleOutcomeCounts(evaluation.coverage),
170
+ findings: findingDisclosure(evaluation.coverage, evaluation.findings.length),
171
+ },
172
+ metrics,
173
+ findings: evaluation.findings,
174
+ ruleCoverage: evaluation.coverage,
175
+ limitations,
176
+ guidance: {
177
+ instanceSecurityTool: "n8n_audit_generate",
178
+ message: "Use n8n_audit_generate for instance-wide security facts; this report inspects one saved workflow and a bounded execution sample.",
179
+ },
180
+ });
181
+ if (!assembled.success)
182
+ throw new IntrospectOutputError();
183
+ return fitStructuredResult(assembled.data);
184
+ }
@@ -0,0 +1,142 @@
1
+ import { IntrospectCollectionError } from "./collector.js";
2
+ function isSafeEncodedPath(path) {
3
+ if (!/^\/[A-Za-z0-9_./%~-]*$/.test(path) || /%(?![A-Fa-f0-9]{2})/.test(path)) {
4
+ return false;
5
+ }
6
+ for (const rawSegment of path.split("/").slice(1)) {
7
+ if (rawSegment === "")
8
+ return false;
9
+ let decoded;
10
+ try {
11
+ decoded = decodeURIComponent(rawSegment);
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ if (decoded === "." ||
17
+ decoded === ".." ||
18
+ decoded.includes("/") ||
19
+ decoded.includes("\\") ||
20
+ decoded.includes("%") ||
21
+ /[\u0000-\u001f\u007f-\u009f]/.test(decoded)) {
22
+ return false;
23
+ }
24
+ }
25
+ return true;
26
+ }
27
+ async function readBoundedBody(response, maxBytes, truncateOnOverflow) {
28
+ if (!response.body)
29
+ return { body: new Uint8Array(), bytes: 0 };
30
+ const reader = response.body.getReader();
31
+ const chunks = [];
32
+ let bytes = 0;
33
+ try {
34
+ while (true) {
35
+ const { done, value } = await reader.read();
36
+ if (done)
37
+ break;
38
+ if (!value)
39
+ continue;
40
+ const remaining = maxBytes - bytes;
41
+ if (value.byteLength > remaining) {
42
+ if (truncateOnOverflow && remaining > 0)
43
+ chunks.push(value.subarray(0, remaining));
44
+ bytes += truncateOnOverflow ? Math.max(remaining, 0) : value.byteLength;
45
+ await reader.cancel();
46
+ if (!truncateOnOverflow) {
47
+ throw new IntrospectCollectionError("response_too_large", "The public n8n API response exceeded the Introspect byte limit.");
48
+ }
49
+ break;
50
+ }
51
+ chunks.push(value);
52
+ bytes += value.byteLength;
53
+ }
54
+ }
55
+ finally {
56
+ reader.releaseLock();
57
+ }
58
+ const body = new Uint8Array(bytes);
59
+ let offset = 0;
60
+ for (const chunk of chunks) {
61
+ body.set(chunk, offset);
62
+ offset += chunk.byteLength;
63
+ }
64
+ return { body, bytes };
65
+ }
66
+ export function createN8nReadClient(options) {
67
+ const fetchImplementation = options.fetchImplementation ?? fetch;
68
+ const baseUrl = new URL(options.baseUrl);
69
+ const basePath = baseUrl.pathname.replace(/\/$/, "");
70
+ const apiRoot = `${basePath}/api/v1`.replace(/\/{2,}/g, "/");
71
+ return {
72
+ async get(endpoint, query, budget) {
73
+ if (!isSafeEncodedPath(endpoint)) {
74
+ throw new IntrospectCollectionError("invalid_path", "The public n8n API path is invalid.");
75
+ }
76
+ options.beforeRequest?.();
77
+ if (!Number.isInteger(budget.timeoutMs) || budget.timeoutMs < 1) {
78
+ throw new IntrospectCollectionError("deadline_exceeded", "The Introspect request timeout is invalid.");
79
+ }
80
+ const controller = new AbortController();
81
+ const timer = setTimeout(() => controller.abort(), budget.timeoutMs);
82
+ try {
83
+ const requestedPath = `${apiRoot}${endpoint}`.replace(/\/{2,}/g, "/");
84
+ const url = new URL(baseUrl.href);
85
+ url.pathname = requestedPath;
86
+ url.search = "";
87
+ for (const [key, value] of Object.entries(query))
88
+ url.searchParams.set(key, value);
89
+ if (url.origin !== baseUrl.origin ||
90
+ url.pathname !== requestedPath ||
91
+ !url.pathname.startsWith(`${apiRoot}/`)) {
92
+ throw new IntrospectCollectionError("invalid_path", "The public n8n API origin or path changed unexpectedly.");
93
+ }
94
+ const response = await fetchImplementation(url, {
95
+ method: "GET",
96
+ headers: {
97
+ "X-N8N-API-KEY": options.apiKey,
98
+ Accept: "application/json",
99
+ },
100
+ signal: controller.signal,
101
+ redirect: "error",
102
+ });
103
+ if (!response.ok) {
104
+ await readBoundedBody(response, 8_192, true);
105
+ throw new IntrospectCollectionError("upstream_http_error", `The public n8n API returned HTTP ${response.status}; verify the workflow ID, API access, and n8n availability.`, response.status);
106
+ }
107
+ const contentLength = response.headers.get("content-length");
108
+ if (contentLength &&
109
+ /^\d+$/.test(contentLength) &&
110
+ Number(contentLength) > budget.maxBytes) {
111
+ await response.body?.cancel();
112
+ throw new IntrospectCollectionError("response_too_large", "The public n8n API declared a response larger than the Introspect byte limit.");
113
+ }
114
+ const body = await readBoundedBody(response, budget.maxBytes, false);
115
+ let text;
116
+ try {
117
+ text = new TextDecoder("utf-8", { fatal: true }).decode(body.body);
118
+ }
119
+ catch {
120
+ throw new IntrospectCollectionError("invalid_json", "The public n8n API response was not valid UTF-8 JSON.");
121
+ }
122
+ try {
123
+ return { value: JSON.parse(text), bytes: body.bytes };
124
+ }
125
+ catch {
126
+ throw new IntrospectCollectionError("invalid_json", "The public n8n API response was not valid JSON.");
127
+ }
128
+ }
129
+ catch (error) {
130
+ if (error instanceof IntrospectCollectionError)
131
+ throw error;
132
+ if (error instanceof Error && error.name === "AbortError") {
133
+ throw new IntrospectCollectionError("deadline_exceeded", "The public n8n API request exceeded the Introspect deadline.");
134
+ }
135
+ throw new IntrospectCollectionError("upstream_http_error", "The public n8n API request failed; verify connectivity and API access.");
136
+ }
137
+ finally {
138
+ clearTimeout(timer);
139
+ }
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,5 @@
1
+ export { IntrospectInputSchema, IntrospectResultSchema } from "./contracts.js";
2
+ export { IntrospectCollectionError } from "./collector.js";
3
+ export { inspectWorkflow, IntrospectInputError, IntrospectOutputError } from "./engine.js";
4
+ export { renderIntrospect } from "./render.js";
5
+ export { createN8nReadClient } from "./http-client.js";
@@ -0,0 +1,3 @@
1
+ export function compareCodeUnits(left, right) {
2
+ return left < right ? -1 : left > right ? 1 : 0;
3
+ }
@@ -0,0 +1,25 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { PROFILE_BUDGETS } from "./collector.js";
3
+ import { IntrospectOutputError } from "./engine.js";
4
+ function percentage(value) {
5
+ return value === null ? "unavailable" : `${(value * 100).toFixed(1)}%`;
6
+ }
7
+ export function renderIntrospect(result) {
8
+ const counts = result.summary.findingCounts;
9
+ const outcomes = result.summary.ruleOutcomes;
10
+ const summary = [
11
+ `n8n workflow Introspect ${result.status}: ${result.workflow.id}.`,
12
+ `Profile ${result.profile}; ${result.sample.metadataExecutions} metadata executions; ${result.sample.detailRequests} detail requests.`,
13
+ `Findings: ${counts.critical} critical, ${counts.high} high, ${counts.medium} medium, ${counts.low} low, ${counts.info} info.`,
14
+ `Rule outcomes: ${outcomes.inconclusive} inconclusive, ${outcomes.partiallyInconclusive} partially inconclusive; limitations: ${result.limitations.length}.`,
15
+ `Observed success rate: ${percentage(result.metrics.successRate)}.`,
16
+ `Ordering: ${result.sample.ordering}; history boundary: ${result.sample.historyBoundary}.`,
17
+ "The following JSON is the authoritative machine-readable report.",
18
+ ].join(" ");
19
+ const json = JSON.stringify(result);
20
+ const combinedBytes = Buffer.byteLength(summary, "utf8") + Buffer.byteLength(json, "utf8") * 2;
21
+ if (combinedBytes > PROFILE_BUDGETS[result.profile].combinedOutputBytes) {
22
+ throw new IntrospectOutputError("The rendered Introspect report exceeds its combined output budget.");
23
+ }
24
+ return { summary, json, combinedBytes };
25
+ }