@skaleagents/swarm 0.6.8 → 0.6.9

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.
@@ -1,4 +1,4 @@
1
- export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose";
1
+ export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github";
2
2
  export type Path = (string | number)[];
3
3
  export type Location = {
4
4
  line: number;
@@ -24,5 +24,7 @@ export declare function array(value: unknown): unknown[];
24
24
  export declare function looksLikeIac(content: string): boolean;
25
25
  export declare function looksLikePulumiProgram(content: string): boolean;
26
26
  export declare function looksLikeCompose(content: string): boolean;
27
+ export declare function looksLikeDockerfile(content: string): boolean;
28
+ export declare function looksLikeGithubWorkflow(content: string): boolean;
27
29
  export declare function looksLikePulumiYaml(content: string): boolean;
28
30
  export declare function parseIac(content: string, requested?: IacFormat | "auto"): ParsedIac;
package/dist/iac/parse.js CHANGED
@@ -40,7 +40,9 @@ export function looksLikeIac(content) {
40
40
  /^\s*\{\s*"(?:resource|Resources|apiVersion|AWSTemplateFormatVersion)"\s*:/.test(content) ||
41
41
  looksLikePulumiYaml(content) ||
42
42
  looksLikePulumiProgram(content) ||
43
- looksLikeCompose(content));
43
+ looksLikeCompose(content) ||
44
+ looksLikeDockerfile(content) ||
45
+ looksLikeGithubWorkflow(content));
44
46
  }
45
47
  export function looksLikePulumiProgram(content) {
46
48
  return /(?:^|\n)\s*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
@@ -48,6 +50,23 @@ export function looksLikePulumiProgram(content) {
48
50
  export function looksLikeCompose(content) {
49
51
  return /^\s*services\s*:/m.test(content) && /^\s+image\s*:/m.test(content);
50
52
  }
53
+ export function looksLikeDockerfile(content) {
54
+ return /^\s*FROM\s+\S+/m.test(content);
55
+ }
56
+ function topLevelKey(content, name) {
57
+ return new RegExp(`^(?:"${name}"|'${name}'|${name})\\s*:`, "m").test(content);
58
+ }
59
+ export function looksLikeGithubWorkflow(content) {
60
+ if (topLevelKey(content, "apiVersion") || topLevelKey(content, "kind"))
61
+ return false;
62
+ const topOn = topLevelKey(content, "on");
63
+ const composeOnly = topLevelKey(content, "services") &&
64
+ /^\s+image\s*:/m.test(content) &&
65
+ !topOn;
66
+ if (composeOnly)
67
+ return false;
68
+ return topOn && topLevelKey(content, "jobs");
69
+ }
51
70
  export function looksLikePulumiYaml(content) {
52
71
  return (/^\s*resources\s*:/m.test(content) &&
53
72
  /^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
@@ -55,6 +74,16 @@ export function looksLikePulumiYaml(content) {
55
74
  export function parseIac(content, requested = "auto") {
56
75
  if (!content.trim())
57
76
  throw new ScanInputError("Content must not be empty or whitespace.");
77
+ // Detect these before HCL or a failing YAML parse so a Dockerfile is not classified as Terraform.
78
+ if (requested === "dockerfile" ||
79
+ ((requested === "auto" || requested === "terraform") &&
80
+ looksLikeDockerfile(content))) {
81
+ return parseDockerfile(content);
82
+ }
83
+ if (requested === "github" ||
84
+ (requested === "auto" && looksLikeGithubWorkflow(content))) {
85
+ return parseGithubWorkflow(content);
86
+ }
58
87
  if (requested !== "terraform" &&
59
88
  requested !== "compose" &&
60
89
  looksLikePulumiProgram(content) &&
@@ -271,7 +300,7 @@ export function parseIac(content, requested = "auto") {
271
300
  }
272
301
  }
273
302
  if (!format)
274
- throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, or Docker Compose.");
303
+ throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, Docker Compose, a Dockerfile, or a GitHub Actions workflow.");
275
304
  if (!resources.length)
276
305
  warnings.push("No resources found in the submitted document.");
277
306
  if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
@@ -352,6 +381,244 @@ function parsePulumiProgram(content) {
352
381
  ],
353
382
  };
354
383
  }
384
+ const submittedTextWarning = "The scan reads the submitted text and does not execute the image or the workflow.";
385
+ function logicalDockerfileLines(content) {
386
+ const raw = content.split(/\r?\n/);
387
+ const lines = [];
388
+ let buffer = "";
389
+ let start = 1;
390
+ for (let index = 0; index < raw.length; index++) {
391
+ const current = raw[index].replace(/\s+$/, "");
392
+ if (!buffer)
393
+ start = index + 1;
394
+ if (current.endsWith("\\")) {
395
+ buffer += `${current.slice(0, -1)} `;
396
+ continue;
397
+ }
398
+ buffer += current;
399
+ const text = buffer.trim();
400
+ buffer = "";
401
+ if (!text || text.startsWith("#"))
402
+ continue;
403
+ lines.push({ line: start, text });
404
+ }
405
+ return lines;
406
+ }
407
+ function parseFromInstruction(text) {
408
+ if (!/^FROM\b/i.test(text))
409
+ return undefined;
410
+ const parts = text.split(/\s+/);
411
+ let index = 1;
412
+ while (index < parts.length && parts[index].startsWith("--")) {
413
+ if (!parts[index].includes("="))
414
+ index += 1;
415
+ index += 1;
416
+ }
417
+ const image = parts[index];
418
+ if (!image)
419
+ return undefined;
420
+ const stage = /^as$/i.test(parts[index + 1] ?? "")
421
+ ? parts[index + 2]
422
+ : undefined;
423
+ return stage ? { image, stage } : { image };
424
+ }
425
+ function imageIsUnpinned(image, stages) {
426
+ if (image.includes("${") ||
427
+ image.includes("@") ||
428
+ stages.has(image) ||
429
+ stages.has(image.toLowerCase()) ||
430
+ image.toLowerCase() === "scratch")
431
+ return false;
432
+ const slash = image.lastIndexOf("/");
433
+ const colon = image.lastIndexOf(":");
434
+ if (colon <= slash)
435
+ return true;
436
+ return image.slice(colon + 1) === "latest";
437
+ }
438
+ function parseUserName(text) {
439
+ if (!/^USER\b/i.test(text))
440
+ return undefined;
441
+ const parts = text
442
+ .split(/\s+/)
443
+ .slice(1)
444
+ .filter((part) => part && !part.startsWith("--"));
445
+ const raw = parts[0];
446
+ if (!raw)
447
+ return undefined;
448
+ return raw.replace(/^["']|["']$/g, "").split(":")[0];
449
+ }
450
+ function runPipesToShell(text) {
451
+ return (/^RUN\b/i.test(text) &&
452
+ /\b(?:curl|wget)\b/i.test(text) &&
453
+ /\|\s*(?:sudo\s+)?(?:\S*\/)?(?:ba)?sh\b/i.test(text));
454
+ }
455
+ function lineLocation(line, path) {
456
+ return { line, column: 1, path };
457
+ }
458
+ function parseDockerfile(content) {
459
+ const instructions = logicalDockerfileLines(content);
460
+ const froms = instructions.flatMap((instruction) => {
461
+ const parsed = parseFromInstruction(instruction.text);
462
+ return parsed ? [{ ...instruction, ...parsed }] : [];
463
+ });
464
+ if (!froms.length)
465
+ throw new ScanInputError("A Dockerfile requires a FROM instruction.");
466
+ const stages = new Set();
467
+ const resources = [];
468
+ froms.forEach((from, index) => {
469
+ resources.push({
470
+ id: `dockerfile.from.${index + 1}`,
471
+ type: "dockerfile:from",
472
+ value: { image: from.image, unpinned: imageIsUnpinned(from.image, stages) },
473
+ locate: () => lineLocation(from.line, "image"),
474
+ });
475
+ if (from.stage) {
476
+ stages.add(from.stage);
477
+ stages.add(from.stage.toLowerCase());
478
+ }
479
+ });
480
+ let lastUser;
481
+ for (const instruction of instructions) {
482
+ const name = parseUserName(instruction.text);
483
+ if (name !== undefined)
484
+ lastUser = { line: instruction.line, name };
485
+ }
486
+ const root = !lastUser || lastUser.name.toLowerCase() === "root" || lastUser.name === "0";
487
+ resources.push({
488
+ id: "dockerfile.user",
489
+ type: "dockerfile:user",
490
+ value: { user: lastUser?.name ?? "", root },
491
+ locate: () => lineLocation(lastUser?.line ?? froms[0].line, "user"),
492
+ });
493
+ instructions.forEach((instruction, index) => {
494
+ if (!/^RUN\b/i.test(instruction.text))
495
+ return;
496
+ resources.push({
497
+ id: `dockerfile.run.${index + 1}`,
498
+ type: "dockerfile:run",
499
+ value: { piped: runPipesToShell(instruction.text) },
500
+ locate: () => lineLocation(instruction.line, "run"),
501
+ });
502
+ });
503
+ return {
504
+ format: "dockerfile",
505
+ resources,
506
+ warnings: [submittedTextWarning],
507
+ };
508
+ }
509
+ function triggersPullRequestTarget(value) {
510
+ if (value === "pull_request_target")
511
+ return true;
512
+ if (Array.isArray(value))
513
+ return value.some((item) => item === "pull_request_target");
514
+ return Object.prototype.hasOwnProperty.call(object(value), "pull_request_target");
515
+ }
516
+ function workflowChecksOutHead(jobs) {
517
+ for (const job of Object.values(object(jobs))) {
518
+ for (const step of array(object(job).steps)) {
519
+ const inputs = object(object(step).with);
520
+ const text = [inputs.ref, inputs.sha]
521
+ .filter((item) => typeof item === "string")
522
+ .join("\n");
523
+ if (text.includes("github.event.pull_request.head"))
524
+ return true;
525
+ }
526
+ }
527
+ return false;
528
+ }
529
+ function permissionsAreBroad(top, jobs, pullRequestTarget) {
530
+ const values = [
531
+ top,
532
+ ...Object.values(object(jobs)).map((job) => object(job).permissions),
533
+ ];
534
+ if (values.some((value) => value === "write-all"))
535
+ return true;
536
+ if (!pullRequestTarget)
537
+ return false;
538
+ return values.some((value) => object(value).contents === "write");
539
+ }
540
+ function parseGithubWorkflow(content) {
541
+ const lines = new LineCounter();
542
+ let documents;
543
+ try {
544
+ documents = parseAllDocuments(content, {
545
+ lineCounter: lines,
546
+ prettyErrors: false,
547
+ });
548
+ }
549
+ catch {
550
+ throw new ScanInputError("Invalid GitHub Actions workflow YAML.");
551
+ }
552
+ const resources = [];
553
+ let found = false;
554
+ for (const [documentIndex, doc] of documents.entries()) {
555
+ if (doc.errors.length)
556
+ throw new ScanInputError("Invalid GitHub Actions workflow YAML.");
557
+ let data;
558
+ try {
559
+ data = doc.toJS({ maxAliasCount: 0 });
560
+ }
561
+ catch {
562
+ throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
563
+ }
564
+ if (data == null)
565
+ continue;
566
+ checkShape(data);
567
+ const root = object(data);
568
+ if (!("on" in root) || !("jobs" in root))
569
+ continue;
570
+ found = true;
571
+ const pullRequestTarget = triggersPullRequestTarget(root.on);
572
+ const checksOutHead = workflowChecksOutHead(root.jobs);
573
+ const broad = permissionsAreBroad(root.permissions, root.jobs, pullRequestTarget);
574
+ const position = (path) => {
575
+ let node = doc.getIn(path, true);
576
+ if (!node?.range)
577
+ node = doc.getIn(["on"], true);
578
+ const at = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
579
+ return {
580
+ line: at.line,
581
+ column: at.col,
582
+ path: [documentIndex, ...path].join("."),
583
+ };
584
+ };
585
+ resources.push({
586
+ id: "workflow.on",
587
+ type: "github:trigger",
588
+ value: { pullRequestTarget, checksOutHead },
589
+ locate: () => position(["on"]),
590
+ });
591
+ resources.push({
592
+ id: "workflow.permissions",
593
+ type: "github:permissions",
594
+ value: { broad },
595
+ locate: () => position(root.permissions != null ? ["permissions"] : ["on"]),
596
+ });
597
+ for (const [jobName, job] of Object.entries(object(root.jobs))) {
598
+ array(object(job).steps).forEach((step, index) => {
599
+ const uses = object(step).uses;
600
+ if (typeof uses !== "string")
601
+ return;
602
+ const ref = uses.includes("@")
603
+ ? uses.slice(uses.lastIndexOf("@") + 1)
604
+ : "";
605
+ resources.push({
606
+ id: `jobs.${jobName}.steps.${index}`,
607
+ type: "github:action",
608
+ value: { uses, floating: ref === "main" || ref === "master" },
609
+ locate: () => position(["jobs", jobName, "steps", index, "uses"]),
610
+ });
611
+ });
612
+ }
613
+ }
614
+ if (!found)
615
+ throw new ScanInputError("A GitHub Actions workflow requires top-level on and jobs keys.");
616
+ return {
617
+ format: "github",
618
+ resources,
619
+ warnings: [submittedTextWarning],
620
+ };
621
+ }
355
622
  function composeServiceMap(root) {
356
623
  if (root.apiVersion || root.kind || root.Resources || root.resources)
357
624
  return undefined;
package/dist/iac/rules.js CHANGED
@@ -197,6 +197,48 @@ export const rules = {
197
197
  detail: "A role grants wildcard verbs, resources, or API groups.",
198
198
  remediation: "Scope the role to the required API groups, resource names, and verbs.",
199
199
  },
200
+ DF001: {
201
+ category: "security",
202
+ severity: "high",
203
+ title: "Dockerfile runs as root",
204
+ detail: "The last USER is root or 0, or the Dockerfile has no USER instruction.",
205
+ remediation: "Add a final USER instruction for a non-root account that the image supports.",
206
+ },
207
+ DF002: {
208
+ category: "security",
209
+ severity: "medium",
210
+ title: "Unpinned container image",
211
+ detail: "A FROM image uses the latest tag, or it has no tag and no digest.",
212
+ remediation: "Pin a release tag or an image digest. Scratch and named stages are not image pins.",
213
+ },
214
+ DF003: {
215
+ category: "security",
216
+ severity: "high",
217
+ title: "Remote script piped to a shell",
218
+ detail: "A RUN instruction downloads a script with curl or wget and pipes it to sh or bash.",
219
+ remediation: "Download the script, review it, and pin a checksum before running it. Do not pipe a remote script to a shell.",
220
+ },
221
+ GH001: {
222
+ category: "security",
223
+ severity: "critical",
224
+ title: "Pull request target checks out PR code",
225
+ detail: "The workflow triggers on pull_request_target and checks out the pull request head ref or head sha.",
226
+ remediation: "Avoid checking out pull request head code from pull_request_target. Use pull_request for untrusted code, or check out the base repository only.",
227
+ },
228
+ GH002: {
229
+ category: "security",
230
+ severity: "high",
231
+ title: "Workflow permissions are broad",
232
+ detail: "permissions is write-all, or a pull_request_target workflow sets contents to write.",
233
+ remediation: "Set the narrowest permissions the workflow needs. Keep contents read-only on pull_request_target.",
234
+ },
235
+ GH003: {
236
+ category: "security",
237
+ severity: "medium",
238
+ title: "Action ref is a floating branch",
239
+ detail: "A uses value is pinned to @main or @master.",
240
+ remediation: "Pin the action to a release tag or a full commit SHA.",
241
+ },
200
242
  };
201
243
  export function resourceFindings(resources, format) {
202
244
  const findings = [];
@@ -212,6 +254,14 @@ export function resourceFindings(resources, format) {
212
254
  location: resource.locate(path),
213
255
  });
214
256
  };
257
+ if (format === "dockerfile") {
258
+ checkDockerfile(resource, check);
259
+ continue;
260
+ }
261
+ if (format === "github") {
262
+ checkGithub(resource, check);
263
+ continue;
264
+ }
215
265
  walk(resource.value, (key, value, path, parent) => {
216
266
  if (typeof value !== "string")
217
267
  return;
@@ -232,6 +282,23 @@ export function resourceFindings(resources, format) {
232
282
  }
233
283
  return { findings, checked };
234
284
  }
285
+ function checkDockerfile(resource, check) {
286
+ if (resource.type === "dockerfile:user")
287
+ check("DF001", resource.value.root === true, ["user"]);
288
+ else if (resource.type === "dockerfile:from")
289
+ check("DF002", resource.value.unpinned === true, ["image"]);
290
+ else if (resource.type === "dockerfile:run")
291
+ check("DF003", resource.value.piped === true, ["run"]);
292
+ }
293
+ function checkGithub(resource, check) {
294
+ if (resource.type === "github:trigger")
295
+ check("GH001", resource.value.pullRequestTarget === true &&
296
+ resource.value.checksOutHead === true, ["on"]);
297
+ else if (resource.type === "github:permissions")
298
+ check("GH002", resource.value.broad === true, ["permissions"]);
299
+ else if (resource.type === "github:action")
300
+ check("GH003", resource.value.floating === true, ["uses"]);
301
+ }
235
302
  function walk(value, visit, path = []) {
236
303
  if (Array.isArray(value))
237
304
  value.forEach((child, index) => walk(child, visit, [...path, index]));
package/dist/server.js CHANGED
@@ -73,12 +73,14 @@ export function createServer(remote = false) {
73
73
  "kubernetes",
74
74
  "pulumi",
75
75
  "compose",
76
+ "dockerfile",
77
+ "github",
76
78
  "application",
77
79
  "auto",
78
80
  ])
79
81
  .optional()
80
82
  .default("auto")
81
- .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, application, auto"),
83
+ .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, application, auto. Includes Dockerfile and GitHub Actions."),
82
84
  },
83
85
  }, async ({ content, focus, format, minSeverity, maxFindings }) => {
84
86
  const botHints = fetchPublicBotHints();
@@ -126,7 +128,7 @@ export function createServer(remote = false) {
126
128
  description: (name === "scan_iac_stub"
127
129
  ? "Compatibility alias for scan_iac; runs the full scanner. "
128
130
  : "") +
129
- "Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
131
+ "Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile and GitHub Actions. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
130
132
  annotations: {
131
133
  readOnlyHint: true,
132
134
  destructiveHint: false,
@@ -134,7 +136,7 @@ export function createServer(remote = false) {
134
136
  },
135
137
  _meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
136
138
  inputSchema: {
137
- content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose"),
139
+ content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose. Includes Dockerfile and GitHub Actions."),
138
140
  format: z
139
141
  .enum([
140
142
  "terraform",
@@ -142,6 +144,8 @@ export function createServer(remote = false) {
142
144
  "kubernetes",
143
145
  "pulumi",
144
146
  "compose",
147
+ "dockerfile",
148
+ "github",
145
149
  "auto",
146
150
  ])
147
151
  .default("auto"),
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.8";
1
+ export declare const VERSION = "0.6.9";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.8";
1
+ export const VERSION = "0.6.9";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"