@skaleagents/swarm 0.6.11 → 0.6.12

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" | "dockerfile" | "github" | "helm" | "ansible" | "bicep" | "arm";
1
+ export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github" | "helm" | "ansible" | "bicep" | "arm" | "gitlab" | "azure-pipelines" | "cloudbuild" | "tfvars";
2
2
  export type Path = (string | number)[];
3
3
  export type Location = {
4
4
  line: number;
package/dist/iac/parse.js CHANGED
@@ -46,7 +46,9 @@ export function looksLikeIac(content) {
46
46
  looksLikeHelm(content) ||
47
47
  looksLikeAnsible(content) ||
48
48
  looksLikeBicep(content) ||
49
- looksLikeArm(content));
49
+ looksLikeArm(content) ||
50
+ looksLikePipeline(content) !== undefined ||
51
+ looksLikeTfvars(content));
50
52
  }
51
53
  export function looksLikePulumiProgram(content) {
52
54
  return /(?:^|\n)\s*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
@@ -154,6 +156,20 @@ export function parseIac(content, requested = "auto") {
154
156
  (requested === "auto" && looksLikeHelm(content))) {
155
157
  return parseHelm(content);
156
158
  }
159
+ const pipeline = requested === "gitlab" ||
160
+ requested === "azure-pipelines" ||
161
+ requested === "cloudbuild"
162
+ ? requested
163
+ : requested === "auto"
164
+ ? looksLikePipeline(content)
165
+ : undefined;
166
+ if (pipeline) {
167
+ if (!pipelineMatches(content, pipeline))
168
+ throw new ScanInputError(pipelineShapeError(pipeline));
169
+ return parsePipeline(content, pipeline);
170
+ }
171
+ if (requested === "tfvars")
172
+ return parseTfvars(content);
157
173
  if (requested === "bicep" || (requested === "auto" && looksLikeBicep(content))) {
158
174
  if (!looksLikeBicep(content))
159
175
  throw new ScanInputError("A Bicep file requires a Microsoft resource declaration.");
@@ -206,14 +222,49 @@ export function parseIac(content, requested = "auto") {
206
222
  });
207
223
  }
208
224
  }
225
+ for (const [name, blocks] of Object.entries(object(object(data).module))) {
226
+ const declaration = new RegExp(`\\bmodule\\s+"${escapeRegex(name)}"`).exec(searchable);
227
+ const offset = declaration?.index ?? 0;
228
+ const before = content.slice(0, offset);
229
+ resources.push({
230
+ id: `module.${name}`,
231
+ type: "terraform:module",
232
+ value: object(array(blocks)[0]),
233
+ locate: (path = []) => ({
234
+ line: before.split("\n").length,
235
+ column: offset - before.lastIndexOf("\n"),
236
+ path: ["module", name, ...path].join("."),
237
+ }),
238
+ });
239
+ }
240
+ for (const [name, blocks] of Object.entries(object(object(data).variable))) {
241
+ const body = object(array(blocks)[0]);
242
+ if (!("default" in body))
243
+ continue;
244
+ const declaration = new RegExp(`\\bvariable\\s+"${escapeRegex(name)}"`).exec(searchable);
245
+ const offset = declaration?.index ?? 0;
246
+ const before = content.slice(0, offset);
247
+ resources.push({
248
+ id: `variable.${name}`,
249
+ type: "terraform:variable",
250
+ value: { name, default: body.default },
251
+ locate: (path = []) => ({
252
+ line: before.split("\n").length,
253
+ column: offset - before.lastIndexOf("\n"),
254
+ path: ["variable", name, ...path].join("."),
255
+ }),
256
+ });
257
+ }
209
258
  if (object(data).module)
210
- warnings.push("External Terraform modules are not expanded. Scan their source separately.");
259
+ warnings.push("External modules are not expanded. The module body was not loaded, and the module source is not fetched or evaluated.");
211
260
  if (JSON.stringify(data).includes("${"))
212
261
  warnings.push("Terraform expressions are not evaluated. Findings use literal values and declared settings.");
213
- if (!resources.length)
214
- warnings.push("No resource declarations found. Variables, data sources, and outputs are not scanned as resources.");
262
+ if (!resources.some((item) => item.type !== "terraform:module" && item.type !== "terraform:variable"))
263
+ warnings.push("No resource declarations found. Data sources and outputs are not scanned as resources.");
215
264
  return { format: "terraform", resources, warnings };
216
265
  }
266
+ if (requested === "auto" && looksLikeTfvars(content))
267
+ return parseTfvars(content);
217
268
  const lines = new LineCounter();
218
269
  const tags = [
219
270
  "Ref",
@@ -616,6 +667,254 @@ function workflowEnvIsLiteral(value) {
616
667
  function lineLocation(line, path) {
617
668
  return { line, column: 1, path };
618
669
  }
670
+ function stolenPipelineInput(content) {
671
+ return (looksLikeGithubWorkflow(content) ||
672
+ looksLikeAnsible(content) ||
673
+ looksLikeHelm(content) ||
674
+ (topLevelKey(content, "apiVersion") && topLevelKey(content, "kind")));
675
+ }
676
+ function azurePipelineShape(content) {
677
+ const poolOrTrigger = topLevelKey(content, "pool") || topLevelKey(content, "trigger");
678
+ const stepsOrStages = topLevelKey(content, "steps") || topLevelKey(content, "stages");
679
+ return poolOrTrigger && stepsOrStages;
680
+ }
681
+ function gitlabShape(content) {
682
+ if (!/\bimage\s*:/.test(content))
683
+ return false;
684
+ if (topLevelKey(content, "stages"))
685
+ return true;
686
+ return /^\s*script\s*:/m.test(content);
687
+ }
688
+ function cloudBuildShape(content) {
689
+ if (!topLevelKey(content, "steps"))
690
+ return false;
691
+ if (topLevelKey(content, "on") && topLevelKey(content, "jobs"))
692
+ return false;
693
+ return /^\s*-\s*name\s*:/m.test(content);
694
+ }
695
+ function looksLikePipeline(content) {
696
+ if (stolenPipelineInput(content))
697
+ return undefined;
698
+ const visible = maskIaCComments(content);
699
+ if (azurePipelineShape(visible))
700
+ return "azure-pipelines";
701
+ if (gitlabShape(visible))
702
+ return "gitlab";
703
+ if (cloudBuildShape(visible))
704
+ return "cloudbuild";
705
+ return undefined;
706
+ }
707
+ function pipelineMatches(content, format) {
708
+ if (stolenPipelineInput(content))
709
+ return false;
710
+ const visible = maskIaCComments(content);
711
+ if (format === "azure-pipelines")
712
+ return azurePipelineShape(visible);
713
+ if (format === "gitlab")
714
+ return gitlabShape(visible);
715
+ return cloudBuildShape(visible);
716
+ }
717
+ function pipelineShapeError(format) {
718
+ if (format === "gitlab")
719
+ return "GitLab CI requires top-level stages or a job with script, and an image.";
720
+ if (format === "azure-pipelines")
721
+ return "Azure Pipelines requires a top-level pool or trigger, plus steps or stages.";
722
+ return "Cloud Build requires top-level steps whose items have a name.";
723
+ }
724
+ const pipelineWarning = "The pipeline is not executed. Only literal text outside comments is checked.";
725
+ function scriptText(value) {
726
+ if (typeof value === "string")
727
+ return value;
728
+ if (Array.isArray(value) && value.every((item) => typeof item === "string"))
729
+ return value.join("\n");
730
+ return undefined;
731
+ }
732
+ function pipelineScriptKey(key) {
733
+ return (key === "script" ||
734
+ key === "before_script" ||
735
+ key === "after_script" ||
736
+ key === "bash" ||
737
+ key === "pwsh" ||
738
+ key === "powershell");
739
+ }
740
+ function pipelinePrintsSecret(script) {
741
+ return script.split(/\n/).some((line) => {
742
+ const code = line.replace(/(^|\s)#.*$/, "").trim();
743
+ if (!/\b(?:echo|printf|print)\b/i.test(code))
744
+ return false;
745
+ return /CI_JOB_TOKEN|secrets\.|\$\(secret|(?:^|[^A-Za-z0-9_])(?:password|passwd|api_key|apikey|auth_token|access_key|private_key|token)(?:[^A-Za-z0-9_]|$)/i.test(code);
746
+ });
747
+ }
748
+ function collectPipelineHits(value, path, format, hits) {
749
+ if (Array.isArray(value)) {
750
+ value.forEach((item, index) => collectPipelineHits(item, [...path, index], format, hits));
751
+ return;
752
+ }
753
+ if (value === null || typeof value !== "object")
754
+ return;
755
+ for (const [key, child] of Object.entries(object(value))) {
756
+ const childPath = [...path, key];
757
+ if ((key === "image" || key === "container") && typeof child === "string")
758
+ hits.push({ kind: "image", path: childPath, image: child });
759
+ else if (key === "image" &&
760
+ typeof object(child).name === "string")
761
+ hits.push({
762
+ kind: "image",
763
+ path: [...childPath, "name"],
764
+ image: String(object(child).name),
765
+ });
766
+ if (key === "privileged" && child === true)
767
+ hits.push({ kind: "privileged", path: childPath });
768
+ const script = pipelineScriptKey(key) ? scriptText(child) : undefined;
769
+ if (script)
770
+ hits.push({ kind: "script", path: childPath, script });
771
+ if (format === "cloudbuild" && key === "steps" && Array.isArray(child)) {
772
+ child.forEach((step, index) => {
773
+ const name = object(step).name;
774
+ if (typeof name === "string")
775
+ hits.push({
776
+ kind: "image",
777
+ path: [...childPath, index, "name"],
778
+ image: name,
779
+ });
780
+ const args = scriptText(object(step).args);
781
+ if (args)
782
+ hits.push({
783
+ kind: "script",
784
+ path: [...childPath, index, "args"],
785
+ script: args,
786
+ });
787
+ });
788
+ }
789
+ collectPipelineHits(child, childPath, format, hits);
790
+ }
791
+ }
792
+ function parsePipeline(content, format) {
793
+ const lines = new LineCounter();
794
+ let documents;
795
+ try {
796
+ documents = parseAllDocuments(content, {
797
+ lineCounter: lines,
798
+ prettyErrors: false,
799
+ });
800
+ }
801
+ catch {
802
+ throw new ScanInputError(pipelineShapeError(format));
803
+ }
804
+ const resources = [];
805
+ let found = false;
806
+ for (const [documentIndex, doc] of documents.entries()) {
807
+ if (doc.errors.length)
808
+ throw new ScanInputError(pipelineShapeError(format));
809
+ let data;
810
+ try {
811
+ data = doc.toJS({ maxAliasCount: 0 });
812
+ }
813
+ catch {
814
+ throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
815
+ }
816
+ if (data == null)
817
+ continue;
818
+ checkShape(data);
819
+ found = true;
820
+ const hits = [];
821
+ collectPipelineHits(data, [], format, hits);
822
+ const position = (path) => {
823
+ let node = doc.getIn(path, true);
824
+ if (!node?.range)
825
+ node = doc.getIn([], true);
826
+ const at = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
827
+ return {
828
+ line: at.line,
829
+ column: at.col,
830
+ path: [documentIndex, ...path].join("."),
831
+ };
832
+ };
833
+ hits.forEach((hit, index) => {
834
+ if (hit.kind === "image" && hit.image)
835
+ resources.push({
836
+ id: `pipeline.image.${index + 1}`,
837
+ type: "pipeline:image",
838
+ value: { unpinned: imageIsUnpinned(hit.image.trim(), new Set()) },
839
+ locate: () => position(hit.path),
840
+ });
841
+ else if (hit.kind === "script" && hit.script)
842
+ resources.push({
843
+ id: `pipeline.script.${index + 1}`,
844
+ type: "pipeline:script",
845
+ value: { printsSecret: pipelinePrintsSecret(hit.script) },
846
+ locate: () => position(hit.path),
847
+ });
848
+ else if (hit.kind === "privileged")
849
+ resources.push({
850
+ id: `pipeline.privileged.${index + 1}`,
851
+ type: "pipeline:privileged",
852
+ value: { present: true },
853
+ locate: () => position(hit.path),
854
+ });
855
+ });
856
+ }
857
+ if (!found)
858
+ throw new ScanInputError(pipelineShapeError(format));
859
+ return { format, resources, warnings: [pipelineWarning] };
860
+ }
861
+ function looksLikeTfvars(content) {
862
+ const visible = maskIaCComments(content);
863
+ if (/^\s*(?:resource|module|provider)\s+["']/m.test(visible))
864
+ return false;
865
+ if (/^\s*(?:terraform|provider|module)\s*\{/m.test(visible))
866
+ return false;
867
+ return /^\s*[A-Za-z_][A-Za-z0-9_]*\s*=\s*\S/m.test(visible);
868
+ }
869
+ function parseTfvars(content) {
870
+ let data;
871
+ try {
872
+ const [parsed, error] = hcl.parseToObject(content);
873
+ if (error || !parsed)
874
+ throw new Error("parse");
875
+ data = parsed;
876
+ }
877
+ catch {
878
+ throw new ScanInputError("Invalid tfvars. Check assignment syntax.");
879
+ }
880
+ checkShape(data);
881
+ const searchable = maskIaCComments(content);
882
+ const resources = [];
883
+ const skipped = new Set([
884
+ "resource",
885
+ "module",
886
+ "terraform",
887
+ "provider",
888
+ "variable",
889
+ "data",
890
+ "output",
891
+ "locals",
892
+ ]);
893
+ for (const [name, value] of Object.entries(object(data))) {
894
+ if (skipped.has(name))
895
+ continue;
896
+ const declaration = new RegExp(`(^|\\n)\\s*${escapeRegex(name)}\\s*=`).exec(searchable);
897
+ const offset = declaration?.index ?? 0;
898
+ const before = content.slice(0, offset);
899
+ resources.push({
900
+ id: `tfvars.${name}`,
901
+ type: "tfvars:assignment",
902
+ value: { name, value },
903
+ locate: (path = []) => ({
904
+ line: before.split("\n").length,
905
+ column: 1,
906
+ path: ["tfvars", name, ...path].join("."),
907
+ }),
908
+ });
909
+ }
910
+ return {
911
+ format: "tfvars",
912
+ resources,
913
+ warnings: [
914
+ "Terraform variable references are not evaluated. Findings use literal values.",
915
+ ],
916
+ };
917
+ }
619
918
  function parseDockerfile(content) {
620
919
  const instructions = logicalDockerfileLines(content);
621
920
  const froms = instructions.flatMap((instruction) => {
package/dist/iac/rules.js CHANGED
@@ -323,6 +323,34 @@ export const rules = {
323
323
  detail: "A cidr or source allows 0.0.0.0/0.",
324
324
  remediation: "Restrict the cidr or source to approved addresses.",
325
325
  },
326
+ PIPE001: {
327
+ category: "security",
328
+ severity: "medium",
329
+ title: "Unpinned container image",
330
+ detail: "An image or step name uses the latest tag, or it has no tag and no digest.",
331
+ remediation: "Pin a release tag or an image digest.",
332
+ },
333
+ PIPE002: {
334
+ category: "security",
335
+ severity: "high",
336
+ title: "Pipeline prints a secret",
337
+ detail: "A script line echoes or prints a secret-like variable.",
338
+ remediation: "Remove the printed secret from the script. Pass secrets through a variable the script does not print.",
339
+ },
340
+ PIPE003: {
341
+ category: "security",
342
+ severity: "critical",
343
+ title: "Elevated container privileges",
344
+ detail: "The pipeline sets privileged to true.",
345
+ remediation: "Set privileged to false. Isolate workloads that genuinely require elevated privileges.",
346
+ },
347
+ TF001: {
348
+ category: "security",
349
+ severity: "high",
350
+ title: "Secret assigned in a Terraform variable",
351
+ detail: "A Terraform variable default or tfvars assignment gives a non-empty literal to a secret-like name.",
352
+ remediation: "Pass the value from a secret store or leave the default empty. Do not commit a literal secret.",
353
+ },
326
354
  };
327
355
  export function resourceFindings(resources, format) {
328
356
  const findings = [];
@@ -358,6 +386,24 @@ export function resourceFindings(resources, format) {
358
386
  checkAzure(resource, check);
359
387
  continue;
360
388
  }
389
+ if (format === "gitlab" ||
390
+ format === "azure-pipelines" ||
391
+ format === "cloudbuild") {
392
+ checkPipeline(resource, check);
393
+ continue;
394
+ }
395
+ if (resource.type === "terraform:module") {
396
+ checkTerraformLiterals(resource.value, "module", check, []);
397
+ continue;
398
+ }
399
+ if (resource.type === "terraform:variable") {
400
+ checkTerraformLiterals({ [String(resource.value.name ?? "default")]: resource.value.default }, "assignment", check, ["default"]);
401
+ continue;
402
+ }
403
+ if (format === "tfvars" || resource.type === "tfvars:assignment") {
404
+ checkTerraformLiterals({ [String(resource.value.name ?? "value")]: resource.value.value }, "assignment", check, []);
405
+ continue;
406
+ }
361
407
  walk(resource.value, (key, value, path, parent) => {
362
408
  if (typeof value !== "string")
363
409
  return;
@@ -419,6 +465,50 @@ function checkAnsible(resource, check) {
419
465
  else if (resource.type === "ansible:cidr")
420
466
  check("ANS002", resource.value.present === true, ["cidr"]);
421
467
  }
468
+ function checkPipeline(resource, check) {
469
+ if (resource.type === "pipeline:image")
470
+ check("PIPE001", resource.value.unpinned === true, ["image"]);
471
+ else if (resource.type === "pipeline:script")
472
+ check("PIPE002", resource.value.printsSecret === true, ["script"]);
473
+ else if (resource.type === "pipeline:privileged")
474
+ check("PIPE003", resource.value.present === true, ["privileged"]);
475
+ }
476
+ function terraformReference(value) {
477
+ const trimmed = value.trim();
478
+ return trimmed.includes("${") || /^var\./.test(trimmed);
479
+ }
480
+ function secretAssignmentName(name) {
481
+ const normalized = name.replace(/-/g, "_");
482
+ return (/(?:^|_)(?:password|passwd|secret|token)$/i.test(normalized) ||
483
+ /(?:^|_)(?:api_?key|auth_token|access_key|private_key)$/i.test(normalized));
484
+ }
485
+ function moduleCidrExempt(name) {
486
+ const normalized = name.trim().toLowerCase().replace(/[\s-]+/g, "_");
487
+ return (normalized === "egress" ||
488
+ normalized === "destination" ||
489
+ normalized === "ipv6_egress");
490
+ }
491
+ function checkTerraformLiterals(value, mode, check, path, name = "") {
492
+ if (typeof value === "string" || typeof value === "boolean") {
493
+ const open = value === "0.0.0.0/0" || value === "::/0";
494
+ const exempt = mode === "module" ? moduleCidrExempt(name) : name.toLowerCase() === "egress";
495
+ if (typeof value === "string")
496
+ check("NET001", open && name !== "" && !exempt, path);
497
+ if (name === "publicly_accessible")
498
+ check("DB001", value === true, path);
499
+ if (mode === "assignment" && typeof value === "string" && secretAssignmentName(name))
500
+ check("TF001", value.trim() !== "" && !terraformReference(value), path);
501
+ return;
502
+ }
503
+ if (Array.isArray(value)) {
504
+ value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name));
505
+ return;
506
+ }
507
+ if (value && typeof value === "object") {
508
+ for (const [key, child] of Object.entries(value))
509
+ checkTerraformLiterals(child, mode, check, [...path, key], key);
510
+ }
511
+ }
422
512
  function checkAzure(resource, check) {
423
513
  if (resource.type === "azure:exposure")
424
514
  check("AZ001", resource.value.present === true, ["sourceAddressPrefix"]);
package/dist/server.js CHANGED
@@ -79,12 +79,16 @@ export function createServer(remote = false) {
79
79
  "ansible",
80
80
  "bicep",
81
81
  "arm",
82
+ "gitlab",
83
+ "azure-pipelines",
84
+ "cloudbuild",
85
+ "tfvars",
82
86
  "application",
83
87
  "auto",
84
88
  ])
85
89
  .optional()
86
90
  .default("auto")
87
- .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, application, auto."),
91
+ .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, application, auto."),
88
92
  },
89
93
  }, async ({ content, focus, format, minSeverity, maxFindings }) => {
90
94
  const botHints = fetchPublicBotHints();
@@ -132,7 +136,7 @@ export function createServer(remote = false) {
132
136
  description: (name === "scan_iac_stub"
133
137
  ? "Compatibility alias for scan_iac; runs the full scanner. "
134
138
  : "") +
135
- "Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, and ARM templates. Helm templates, Ansible facts, Bicep modules, and ARM expressions are not executed. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
139
+ "Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, ARM templates, GitLab CI, Azure Pipelines, Cloud Build, and tfvars. Helm templates, Ansible facts, Bicep modules, ARM expressions, and pipelines are not executed. Terraform module sources are not fetched. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
136
140
  annotations: {
137
141
  readOnlyHint: true,
138
142
  destructiveHint: false,
@@ -140,7 +144,7 @@ export function createServer(remote = false) {
140
144
  },
141
145
  _meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
142
146
  inputSchema: {
143
- content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, and ARM templates."),
147
+ content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, ARM templates, GitLab CI, Azure Pipelines, Cloud Build, and tfvars."),
144
148
  format: z
145
149
  .enum([
146
150
  "terraform",
@@ -154,6 +158,10 @@ export function createServer(remote = false) {
154
158
  "ansible",
155
159
  "bicep",
156
160
  "arm",
161
+ "gitlab",
162
+ "azure-pipelines",
163
+ "cloudbuild",
164
+ "tfvars",
157
165
  "auto",
158
166
  ])
159
167
  .default("auto"),
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.11";
1
+ export declare const VERSION = "0.6.12";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.11";
1
+ export const VERSION = "0.6.12";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"