@skaleagents/swarm 0.6.12 → 0.6.13

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" | "gitlab" | "azure-pipelines" | "cloudbuild" | "tfvars";
1
+ export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github" | "helm" | "ansible" | "bicep" | "arm" | "gitlab" | "azure-pipelines" | "cloudbuild" | "tfvars" | "serverless" | "iam";
2
2
  export type Path = (string | number)[];
3
3
  export type Location = {
4
4
  line: number;
@@ -33,3 +33,5 @@ export declare function looksLikeBicep(content: string): boolean;
33
33
  export declare function looksLikeArm(content: string): boolean;
34
34
  export declare function looksLikePulumiYaml(content: string): boolean;
35
35
  export declare function parseIac(content: string, requested?: IacFormat | "auto"): ParsedIac;
36
+ export declare function looksLikeServerless(content: string): boolean;
37
+ export declare function looksLikeIamPolicy(content: string): boolean;
package/dist/iac/parse.js CHANGED
@@ -30,6 +30,8 @@ export function looksLikeIac(content) {
30
30
  const value = object(JSON.parse(content));
31
31
  if (value.resource || value.Resources || (value.apiVersion && value.kind))
32
32
  return true;
33
+ if (looksLikeIamPolicy(content))
34
+ return true;
33
35
  }
34
36
  catch {
35
37
  /* Other format detection still applies to incomplete input. */
@@ -48,7 +50,9 @@ export function looksLikeIac(content) {
48
50
  looksLikeBicep(content) ||
49
51
  looksLikeArm(content) ||
50
52
  looksLikePipeline(content) !== undefined ||
51
- looksLikeTfvars(content));
53
+ looksLikeTfvars(content) ||
54
+ looksLikeServerless(content) ||
55
+ looksLikeIamPolicy(content));
52
56
  }
53
57
  export function looksLikePulumiProgram(content) {
54
58
  return /(?:^|\n)\s*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
@@ -135,6 +139,7 @@ export function looksLikePulumiYaml(content) {
135
139
  return (/^\s*resources\s*:/m.test(content) &&
136
140
  /^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
137
141
  }
142
+ const terraformExpressionWarning = "Terraform expressions are not evaluated. Findings use literal values and declared settings.";
138
143
  export function parseIac(content, requested = "auto") {
139
144
  if (!content.trim())
140
145
  throw new ScanInputError("Content must not be empty or whitespace.");
@@ -156,6 +161,14 @@ export function parseIac(content, requested = "auto") {
156
161
  (requested === "auto" && looksLikeHelm(content))) {
157
162
  return parseHelm(content);
158
163
  }
164
+ if (requested === "serverless" ||
165
+ (requested === "auto" && looksLikeServerless(content))) {
166
+ return parseServerless(content);
167
+ }
168
+ if (requested === "iam" ||
169
+ (requested === "auto" && looksLikeIamPolicy(content))) {
170
+ return parseIamPolicy(content);
171
+ }
159
172
  const pipeline = requested === "gitlab" ||
160
173
  requested === "azure-pipelines" ||
161
174
  requested === "cloudbuild"
@@ -257,8 +270,29 @@ export function parseIac(content, requested = "auto") {
257
270
  }
258
271
  if (object(data).module)
259
272
  warnings.push("External modules are not expanded. The module body was not loaded, and the module source is not fetched or evaluated.");
273
+ const localBlocks = terraformLocalBlocks(data);
274
+ const localDeclarations = [
275
+ ...searchable.matchAll(/\blocals\s*(?:"[^"]+"\s*)?\{/g),
276
+ ];
277
+ localBlocks.forEach((block, index) => {
278
+ const offset = localDeclarations[index]?.index ?? 0;
279
+ const before = content.slice(0, offset);
280
+ resources.push({
281
+ id: `locals.${index + 1}`,
282
+ type: "terraform:local",
283
+ value: block,
284
+ locate: (path = []) => ({
285
+ line: before.split("\n").length,
286
+ column: offset - before.lastIndexOf("\n"),
287
+ path: ["locals", index, ...path].join("."),
288
+ }),
289
+ });
290
+ });
260
291
  if (JSON.stringify(data).includes("${"))
261
- warnings.push("Terraform expressions are not evaluated. Findings use literal values and declared settings.");
292
+ warnings.push(terraformExpressionWarning);
293
+ if (localBlocks.length > 0 &&
294
+ !warnings.some((warning) => warning.includes("expressions are not evaluated")))
295
+ warnings.push(terraformExpressionWarning);
262
296
  if (!resources.some((item) => item.type !== "terraform:module" && item.type !== "terraform:variable"))
263
297
  warnings.push("No resource declarations found. Data sources and outputs are not scanned as resources.");
264
298
  return { format: "terraform", resources, warnings };
@@ -430,7 +464,7 @@ export function parseIac(content, requested = "auto") {
430
464
  }
431
465
  }
432
466
  if (!format)
433
- throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, Docker Compose, a Dockerfile, a GitHub Actions workflow, a Helm template, an Ansible playbook, Bicep, or an ARM template.");
467
+ throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, Docker Compose, a Dockerfile, a GitHub Actions workflow, a Helm template, an Ansible playbook, Bicep, an ARM template, Serverless Framework, or a standalone IAM policy.");
434
468
  if (!resources.length)
435
469
  warnings.push("No resources found in the submitted document.");
436
470
  if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
@@ -693,7 +727,7 @@ function cloudBuildShape(content) {
693
727
  return /^\s*-\s*name\s*:/m.test(content);
694
728
  }
695
729
  function looksLikePipeline(content) {
696
- if (stolenPipelineInput(content))
730
+ if (stolenPipelineInput(content) || looksLikeServerless(content))
697
731
  return undefined;
698
732
  const visible = maskIaCComments(content);
699
733
  if (azurePipelineShape(visible))
@@ -1369,3 +1403,229 @@ function parseAzure(content, format) {
1369
1403
  warnings: [azureExpressionWarning],
1370
1404
  };
1371
1405
  }
1406
+ function terraformLocalBlocks(data) {
1407
+ const raw = object(data).locals;
1408
+ if (Array.isArray(raw))
1409
+ return raw.map((block) => object(block));
1410
+ if (raw && typeof raw === "object") {
1411
+ return Object.values(object(raw)).flatMap((blocks) => array(blocks).map((block) => object(block)));
1412
+ }
1413
+ return [];
1414
+ }
1415
+ export function looksLikeServerless(content) {
1416
+ if (looksLikeGithubWorkflow(content) || looksLikeCompose(content))
1417
+ return false;
1418
+ if (topLevelKey(content, "apiVersion") && topLevelKey(content, "kind"))
1419
+ return false;
1420
+ if (!topLevelKey(content, "service") || !topLevelKey(content, "provider"))
1421
+ return false;
1422
+ return topLevelKey(content, "functions") || topLevelKey(content, "plugins");
1423
+ }
1424
+ export function looksLikeIamPolicy(content) {
1425
+ const trimmed = content.trim();
1426
+ if (!trimmed.startsWith("{"))
1427
+ return false;
1428
+ let value;
1429
+ try {
1430
+ value = JSON.parse(trimmed);
1431
+ }
1432
+ catch {
1433
+ return false;
1434
+ }
1435
+ const root = object(value);
1436
+ if (typeof root.AWSTemplateFormatVersion === "string")
1437
+ return false;
1438
+ if ("resource" in root)
1439
+ return false;
1440
+ const version = root.Version ?? root.version;
1441
+ if (typeof version !== "string" || !version.includes("2012-10-17"))
1442
+ return false;
1443
+ const statement = root.Statement ?? root.statement;
1444
+ return Array.isArray(statement);
1445
+ }
1446
+ const serverlessWarning = "The Serverless Framework config is not executed. Conditions are not evaluated.";
1447
+ const iamPolicyWarning = "The IAM policy is not executed. Conditions are not evaluated.";
1448
+ function serverlessShapeError() {
1449
+ return "Serverless Framework config requires top-level service and provider, plus functions or plugins.";
1450
+ }
1451
+ function iamShapeError() {
1452
+ return "An IAM policy requires Version 2012-10-17 and a Statement array.";
1453
+ }
1454
+ function effectAllows(body) {
1455
+ const effect = body.Effect ?? body.effect;
1456
+ if (effect == null || String(effect).trim() === "")
1457
+ return true;
1458
+ return String(effect).trim().toLowerCase() === "allow";
1459
+ }
1460
+ function actionIsOnlyWildcard(body) {
1461
+ const action = body.Action ?? body.action ?? body.actions ?? body.Actions;
1462
+ if (typeof action === "string")
1463
+ return action.trim() === "*";
1464
+ return (Array.isArray(action) &&
1465
+ action.length > 0 &&
1466
+ action.every((item) => typeof item === "string" && item.trim() === "*"));
1467
+ }
1468
+ function serverlessReference(value) {
1469
+ return /^\$\{[\s\S]+\}$/.test(value.trim());
1470
+ }
1471
+ function yamlLocation(doc, lines, path) {
1472
+ const node = doc.getIn(path, true);
1473
+ const position = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
1474
+ return {
1475
+ line: position.line,
1476
+ column: position.col,
1477
+ path: path.join("."),
1478
+ };
1479
+ }
1480
+ function statementGroups(root) {
1481
+ const groups = [];
1482
+ const seen = new Set();
1483
+ const add = (statements, path) => {
1484
+ if (!Array.isArray(statements) || seen.has(statements))
1485
+ return;
1486
+ seen.add(statements);
1487
+ groups.push({ path, statements });
1488
+ };
1489
+ const provider = object(root.provider);
1490
+ add(provider.iamRoleStatements, ["provider", "iamRoleStatements"]);
1491
+ add(root.iamRoleStatements, ["iamRoleStatements"]);
1492
+ add(object(object(provider.iam).role).statements, ["provider", "iam", "role", "statements"]);
1493
+ add(object(object(root.iam).role).statements, ["iam", "role", "statements"]);
1494
+ if (Array.isArray(provider.iam))
1495
+ add(provider.iam, ["provider", "iam"]);
1496
+ else
1497
+ add(object(provider.iam).statements, ["provider", "iam", "statements"]);
1498
+ return groups;
1499
+ }
1500
+ function parseServerless(content) {
1501
+ if (!looksLikeServerless(content))
1502
+ throw new ScanInputError(serverlessShapeError());
1503
+ const lines = new LineCounter();
1504
+ let documents;
1505
+ try {
1506
+ documents = parseAllDocuments(content, {
1507
+ lineCounter: lines,
1508
+ prettyErrors: false,
1509
+ });
1510
+ }
1511
+ catch {
1512
+ throw new ScanInputError(serverlessShapeError());
1513
+ }
1514
+ const doc = documents.find((item) => item.toJS() != null);
1515
+ if (!doc || doc.errors.length)
1516
+ throw new ScanInputError(serverlessShapeError());
1517
+ let data;
1518
+ try {
1519
+ data = doc.toJS({ maxAliasCount: 0 });
1520
+ }
1521
+ catch {
1522
+ throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
1523
+ }
1524
+ const root = object(data);
1525
+ if (root.service == null ||
1526
+ root.provider == null ||
1527
+ (root.functions == null && root.plugins == null))
1528
+ throw new ScanInputError(serverlessShapeError());
1529
+ const resources = [];
1530
+ let iamCount = 0;
1531
+ for (const group of statementGroups(root)) {
1532
+ group.statements.forEach((statement, index) => {
1533
+ const body = object(statement);
1534
+ if (!effectAllows(body) || !actionIsOnlyWildcard(body))
1535
+ return;
1536
+ iamCount += 1;
1537
+ const path = [...group.path, index, "Action"];
1538
+ resources.push({
1539
+ id: `serverless.iam.${iamCount}`,
1540
+ type: "serverless:iam",
1541
+ value: { wildcard: true },
1542
+ locate: () => yamlLocation(doc, lines, path),
1543
+ });
1544
+ });
1545
+ }
1546
+ if (iamCount === 0) {
1547
+ resources.push({
1548
+ id: "serverless.iam",
1549
+ type: "serverless:iam",
1550
+ value: { wildcard: false },
1551
+ locate: () => yamlLocation(doc, lines, ["provider", "iamRoleStatements"]),
1552
+ });
1553
+ }
1554
+ const envHits = [];
1555
+ for (const [key, value] of Object.entries(object(object(root.provider).environment))) {
1556
+ if (secretVariableName(key))
1557
+ envHits.push({ path: ["provider", "environment", key], key, value });
1558
+ }
1559
+ for (const [name, raw] of Object.entries(object(root.functions))) {
1560
+ for (const [key, value] of Object.entries(object(object(raw).environment))) {
1561
+ if (secretVariableName(key))
1562
+ envHits.push({ path: ["functions", name, "environment", key], key, value });
1563
+ }
1564
+ }
1565
+ if (envHits.length === 0) {
1566
+ resources.push({
1567
+ id: "serverless.environment",
1568
+ type: "serverless:env",
1569
+ value: { literal: false },
1570
+ locate: () => yamlLocation(doc, lines, ["provider", "environment"]),
1571
+ });
1572
+ }
1573
+ else {
1574
+ envHits.forEach((hit, index) => {
1575
+ const literal = typeof hit.value === "string" &&
1576
+ hit.value.trim() !== "" &&
1577
+ !serverlessReference(hit.value);
1578
+ resources.push({
1579
+ id: `serverless.env.${index + 1}`,
1580
+ type: "serverless:env",
1581
+ value: { literal },
1582
+ locate: () => yamlLocation(doc, lines, hit.path),
1583
+ });
1584
+ });
1585
+ }
1586
+ return { format: "serverless", resources, warnings: [serverlessWarning] };
1587
+ }
1588
+ function parseIamPolicy(content) {
1589
+ if (!looksLikeIamPolicy(content))
1590
+ throw new ScanInputError(iamShapeError());
1591
+ let root;
1592
+ try {
1593
+ root = object(JSON.parse(content));
1594
+ }
1595
+ catch {
1596
+ throw new ScanInputError(iamShapeError());
1597
+ }
1598
+ const statements = array(root.Statement ?? root.statement);
1599
+ const resources = [];
1600
+ statements.forEach((statement, index) => {
1601
+ const body = object(statement);
1602
+ if (!effectAllows(body) || !actionIsOnlyWildcard(body))
1603
+ return;
1604
+ const line = iamStatementLine(content, index);
1605
+ resources.push({
1606
+ id: `iam.statement.${index + 1}`,
1607
+ type: "iam:statement",
1608
+ value: { wildcard: true },
1609
+ locate: () => lineLocation(line, `Statement.${index}.Action`),
1610
+ });
1611
+ });
1612
+ if (!resources.length) {
1613
+ resources.push({
1614
+ id: "iam.policy",
1615
+ type: "iam:statement",
1616
+ value: { wildcard: false },
1617
+ locate: () => lineLocation(1, "Statement"),
1618
+ });
1619
+ }
1620
+ return { format: "iam", resources, warnings: [iamPolicyWarning] };
1621
+ }
1622
+ function iamStatementLine(content, index) {
1623
+ const matches = [...content.matchAll(/"Statement"\s*:/g)];
1624
+ const start = matches[0]?.index ?? 0;
1625
+ const region = content.slice(start);
1626
+ const actions = [...region.matchAll(/"Action"\s*:/g)];
1627
+ const action = actions[index];
1628
+ if (!action || action.index == null)
1629
+ return lineAt(content, start);
1630
+ return lineAt(content, start + action.index);
1631
+ }
package/dist/iac/rules.js CHANGED
@@ -351,6 +351,20 @@ export const rules = {
351
351
  detail: "A Terraform variable default or tfvars assignment gives a non-empty literal to a secret-like name.",
352
352
  remediation: "Pass the value from a secret store or leave the default empty. Do not commit a literal secret.",
353
353
  },
354
+ TF002: {
355
+ category: "security",
356
+ severity: "high",
357
+ title: "Secret assigned in a Terraform local",
358
+ detail: "A Terraform local assigns a non-empty literal to a secret-like name.",
359
+ remediation: "Pass the value from a secret store or a variable reference. Do not commit a literal secret in locals.",
360
+ },
361
+ SLS001: {
362
+ category: "security",
363
+ severity: "high",
364
+ title: "Secret assigned in serverless environment",
365
+ detail: "A Serverless Framework environment entry assigns a non-empty literal to a secret-like name.",
366
+ remediation: "Reference the value with a Serverless variable such as ${env:TOKEN}. Do not commit a literal secret.",
367
+ },
354
368
  };
355
369
  export function resourceFindings(resources, format) {
356
370
  const findings = [];
@@ -392,6 +406,18 @@ export function resourceFindings(resources, format) {
392
406
  checkPipeline(resource, check);
393
407
  continue;
394
408
  }
409
+ if (format === "serverless") {
410
+ checkServerless(resource, check);
411
+ continue;
412
+ }
413
+ if (format === "iam") {
414
+ check("IAM001", resource.value.wildcard === true, ["Action"]);
415
+ continue;
416
+ }
417
+ if (resource.type === "terraform:local") {
418
+ checkTerraformLiterals(resource.value, "local", check, []);
419
+ continue;
420
+ }
395
421
  if (resource.type === "terraform:module") {
396
422
  checkTerraformLiterals(resource.value, "module", check, []);
397
423
  continue;
@@ -473,6 +499,12 @@ function checkPipeline(resource, check) {
473
499
  else if (resource.type === "pipeline:privileged")
474
500
  check("PIPE003", resource.value.present === true, ["privileged"]);
475
501
  }
502
+ function checkServerless(resource, check) {
503
+ if (resource.type === "serverless:iam")
504
+ check("IAM001", resource.value.wildcard === true, ["Action"]);
505
+ else if (resource.type === "serverless:env")
506
+ check("SLS001", resource.value.literal === true, ["environment"]);
507
+ }
476
508
  function terraformReference(value) {
477
509
  const trimmed = value.trim();
478
510
  return trimmed.includes("${") || /^var\./.test(trimmed);
@@ -491,13 +523,15 @@ function moduleCidrExempt(name) {
491
523
  function checkTerraformLiterals(value, mode, check, path, name = "") {
492
524
  if (typeof value === "string" || typeof value === "boolean") {
493
525
  const open = value === "0.0.0.0/0" || value === "::/0";
494
- const exempt = mode === "module" ? moduleCidrExempt(name) : name.toLowerCase() === "egress";
526
+ const exempt = mode === "assignment" ? name.toLowerCase() === "egress" : moduleCidrExempt(name);
495
527
  if (typeof value === "string")
496
528
  check("NET001", open && name !== "" && !exempt, path);
497
529
  if (name === "publicly_accessible")
498
530
  check("DB001", value === true, path);
499
531
  if (mode === "assignment" && typeof value === "string" && secretAssignmentName(name))
500
532
  check("TF001", value.trim() !== "" && !terraformReference(value), path);
533
+ if (mode === "local" && typeof value === "string" && secretAssignmentName(name))
534
+ check("TF002", value.trim() !== "" && !terraformReference(value), path);
501
535
  return;
502
536
  }
503
537
  if (Array.isArray(value)) {
package/dist/server.js CHANGED
@@ -83,12 +83,14 @@ export function createServer(remote = false) {
83
83
  "azure-pipelines",
84
84
  "cloudbuild",
85
85
  "tfvars",
86
+ "serverless",
87
+ "iam",
86
88
  "application",
87
89
  "auto",
88
90
  ])
89
91
  .optional()
90
92
  .default("auto")
91
- .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, application, auto."),
93
+ .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, serverless, iam, application, auto."),
92
94
  },
93
95
  }, async ({ content, focus, format, minSeverity, maxFindings }) => {
94
96
  const botHints = fetchPublicBotHints();
@@ -136,7 +138,7 @@ export function createServer(remote = false) {
136
138
  description: (name === "scan_iac_stub"
137
139
  ? "Compatibility alias for scan_iac; runs the full scanner. "
138
140
  : "") +
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.",
141
+ "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, tfvars, Serverless Framework, and a standalone IAM policy. Helm templates, Ansible facts, Bicep modules, ARM expressions, pipelines, Serverless Framework config, and IAM policies are not executed. Conditions are not evaluated. Terraform module sources are not fetched. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
140
142
  annotations: {
141
143
  readOnlyHint: true,
142
144
  destructiveHint: false,
@@ -144,7 +146,7 @@ export function createServer(remote = false) {
144
146
  },
145
147
  _meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
146
148
  inputSchema: {
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."),
149
+ 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, tfvars, Serverless Framework, and a standalone IAM policy."),
148
150
  format: z
149
151
  .enum([
150
152
  "terraform",
@@ -162,6 +164,8 @@ export function createServer(remote = false) {
162
164
  "azure-pipelines",
163
165
  "cloudbuild",
164
166
  "tfvars",
167
+ "serverless",
168
+ "iam",
165
169
  "auto",
166
170
  ])
167
171
  .default("auto"),
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.12";
1
+ export declare const VERSION = "0.6.13";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.12";
1
+ export const VERSION = "0.6.13";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.12",
3
+ "version": "0.6.13",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"