@skaleagents/swarm 0.6.12 → 0.6.14

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 = "Same-file variable and local literals are resolved. Functions and other expressions are not evaluated.";
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 };
@@ -378,11 +412,12 @@ export function parseIac(content, requested = "auto") {
378
412
  Array.isArray(root.Resources) ||
379
413
  typeof root.Resources !== "object")
380
414
  throw new ScanInputError("CloudFormation requires a Resources mapping.");
415
+ const parameters = cloudFormationParameterLiterals(root);
381
416
  for (const [name, raw] of Object.entries(object(root.Resources))) {
382
417
  const value = object(raw);
383
418
  if (typeof value.Type !== "string")
384
419
  throw new ScanInputError("Each CloudFormation resource requires a Type.");
385
- add(name, value.Type, value, ["Resources", name]);
420
+ add(name, value.Type, resolveCloudFormationRefs(value, parameters), ["Resources", name]);
386
421
  }
387
422
  }
388
423
  else if (format === "terraform") {
@@ -430,10 +465,13 @@ export function parseIac(content, requested = "auto") {
430
465
  }
431
466
  }
432
467
  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.");
468
+ 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
469
  if (!resources.length)
435
470
  warnings.push("No resources found in the submitted document.");
436
- if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
471
+ if (format === "cloudformation" &&
472
+ /(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\bRef\s*:)/.test(content))
473
+ warnings.push("Same-file parameter defaults are resolved. Functions are not evaluated.");
474
+ else if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
437
475
  warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
438
476
  return { format, resources, warnings };
439
477
  }
@@ -693,7 +731,7 @@ function cloudBuildShape(content) {
693
731
  return /^\s*-\s*name\s*:/m.test(content);
694
732
  }
695
733
  function looksLikePipeline(content) {
696
- if (stolenPipelineInput(content))
734
+ if (stolenPipelineInput(content) || looksLikeServerless(content))
697
735
  return undefined;
698
736
  const visible = maskIaCComments(content);
699
737
  if (azurePipelineShape(visible))
@@ -1369,3 +1407,338 @@ function parseAzure(content, format) {
1369
1407
  warnings: [azureExpressionWarning],
1370
1408
  };
1371
1409
  }
1410
+ function parameterNameIsSecret(name) {
1411
+ const normalized = name.replace(/-/g, "_");
1412
+ return (/(?:^|_)(?:password|passwd|secret|token)$/i.test(normalized) ||
1413
+ /(?:^|_)(?:api_?key|auth_token|access_key|private_key)$/i.test(normalized) ||
1414
+ /(?:password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key|token)$/i.test(name));
1415
+ }
1416
+ function cloudFormationParameterLiterals(root) {
1417
+ const defaults = new Map();
1418
+ const secretNames = new Set();
1419
+ for (const [name, raw] of Object.entries(object(root.Parameters))) {
1420
+ const body = object(raw);
1421
+ if (!("Default" in body))
1422
+ continue;
1423
+ const value = body.Default;
1424
+ if (typeof value !== "string" && typeof value !== "boolean" && typeof value !== "number")
1425
+ continue;
1426
+ defaults.set(name, value);
1427
+ if (typeof value === "string" && value.trim() !== "" && parameterNameIsSecret(name))
1428
+ secretNames.add(name);
1429
+ }
1430
+ return { defaults, secretNames };
1431
+ }
1432
+ function cloudFormationRefName(value) {
1433
+ if (!value || typeof value !== "object" || Array.isArray(value))
1434
+ return undefined;
1435
+ const entries = Object.entries(value).filter(([key]) => key !== "__referencedSecrets");
1436
+ if (entries.length !== 1)
1437
+ return undefined;
1438
+ const [key, name] = entries[0];
1439
+ if (key !== "Ref" || typeof name !== "string" || name.trim() === "")
1440
+ return undefined;
1441
+ return name;
1442
+ }
1443
+ function cloudFormationIntrinsic(value) {
1444
+ if (!value || typeof value !== "object" || Array.isArray(value))
1445
+ return false;
1446
+ return Object.keys(value).some((key) => key === "Fn::Sub" || key === "Fn::Join" || key === "Fn::FindInMap" || key.startsWith("Fn::"));
1447
+ }
1448
+ function resolveCloudFormationRefs(value, parameters) {
1449
+ const secretPaths = [];
1450
+ const resolved = resolveCloudFormationValue(value, parameters, [], secretPaths);
1451
+ if (secretPaths.length > 0 &&
1452
+ resolved &&
1453
+ typeof resolved === "object" &&
1454
+ !Array.isArray(resolved)) {
1455
+ Object.defineProperty(resolved, "__referencedSecrets", {
1456
+ enumerable: false,
1457
+ configurable: true,
1458
+ value: secretPaths,
1459
+ });
1460
+ }
1461
+ return object(resolved);
1462
+ }
1463
+ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1464
+ const ref = cloudFormationRefName(value);
1465
+ if (ref) {
1466
+ if (!parameters.defaults.has(ref))
1467
+ return value;
1468
+ const literal = parameters.defaults.get(ref);
1469
+ if (parameters.secretNames.has(ref) &&
1470
+ typeof literal === "string" &&
1471
+ literal.trim() !== "")
1472
+ secretPaths.push(path);
1473
+ return literal;
1474
+ }
1475
+ if (cloudFormationIntrinsic(value))
1476
+ return value;
1477
+ if (Array.isArray(value))
1478
+ return value.map((item, index) => resolveCloudFormationValue(item, parameters, [...path, index], secretPaths));
1479
+ if (value && typeof value === "object") {
1480
+ const out = {};
1481
+ for (const [key, child] of Object.entries(value)) {
1482
+ if (key === "__referencedSecrets")
1483
+ continue;
1484
+ out[key] = resolveCloudFormationValue(child, parameters, [...path, key], secretPaths);
1485
+ }
1486
+ return out;
1487
+ }
1488
+ return value;
1489
+ }
1490
+ function terraformLocalBlocks(data) {
1491
+ const raw = object(data).locals;
1492
+ if (Array.isArray(raw))
1493
+ return raw.map((block) => object(block));
1494
+ if (raw && typeof raw === "object") {
1495
+ return Object.values(object(raw)).flatMap((blocks) => array(blocks).map((block) => object(block)));
1496
+ }
1497
+ return [];
1498
+ }
1499
+ export function looksLikeServerless(content) {
1500
+ if (looksLikeGithubWorkflow(content) || looksLikeCompose(content))
1501
+ return false;
1502
+ if (topLevelKey(content, "apiVersion") && topLevelKey(content, "kind"))
1503
+ return false;
1504
+ if (!topLevelKey(content, "service") || !topLevelKey(content, "provider"))
1505
+ return false;
1506
+ return topLevelKey(content, "functions") || topLevelKey(content, "plugins");
1507
+ }
1508
+ export function looksLikeIamPolicy(content) {
1509
+ const trimmed = content.trim();
1510
+ if (!trimmed.startsWith("{"))
1511
+ return false;
1512
+ let value;
1513
+ try {
1514
+ value = JSON.parse(trimmed);
1515
+ }
1516
+ catch {
1517
+ return false;
1518
+ }
1519
+ const root = object(value);
1520
+ if (typeof root.AWSTemplateFormatVersion === "string")
1521
+ return false;
1522
+ if ("resource" in root)
1523
+ return false;
1524
+ const version = root.Version ?? root.version;
1525
+ if (typeof version !== "string" || !version.includes("2012-10-17"))
1526
+ return false;
1527
+ const statement = root.Statement ?? root.statement;
1528
+ return Array.isArray(statement);
1529
+ }
1530
+ const serverlessWarning = "The Serverless Framework config is not executed. Other condition operators are not evaluated.";
1531
+ const iamPolicyWarning = "The IAM policy is not executed. Other condition operators are not evaluated.";
1532
+ function serverlessShapeError() {
1533
+ return "Serverless Framework config requires top-level service and provider, plus functions or plugins.";
1534
+ }
1535
+ function iamShapeError() {
1536
+ return "An IAM policy requires Version 2012-10-17 and a Statement array.";
1537
+ }
1538
+ function effectAllows(body) {
1539
+ const effect = body.Effect ?? body.effect;
1540
+ if (effect == null || String(effect).trim() === "")
1541
+ return true;
1542
+ return String(effect).trim().toLowerCase() === "allow";
1543
+ }
1544
+ const openSourceAddresses = new Set(["0.0.0.0/0", "::/0", "*"]);
1545
+ function conditionValueIsOpen(value) {
1546
+ if (typeof value === "string")
1547
+ return openSourceAddresses.has(value.trim());
1548
+ if (Array.isArray(value))
1549
+ return value.some((item) => conditionValueIsOpen(item));
1550
+ return false;
1551
+ }
1552
+ function sourceAddressAllowsEveryone(body) {
1553
+ if (!effectAllows(body))
1554
+ return false;
1555
+ const condition = object(body.Condition ?? body.condition);
1556
+ let ipAddress;
1557
+ for (const [key, value] of Object.entries(condition)) {
1558
+ if (key.toLowerCase() === "ipaddress") {
1559
+ ipAddress = value;
1560
+ break;
1561
+ }
1562
+ }
1563
+ if (!ipAddress || typeof ipAddress !== "object" || Array.isArray(ipAddress))
1564
+ return false;
1565
+ return Object.values(object(ipAddress)).some((value) => conditionValueIsOpen(value));
1566
+ }
1567
+ function actionIsOnlyWildcard(body) {
1568
+ const action = body.Action ?? body.action ?? body.actions ?? body.Actions;
1569
+ if (typeof action === "string")
1570
+ return action.trim() === "*";
1571
+ return (Array.isArray(action) &&
1572
+ action.length > 0 &&
1573
+ action.every((item) => typeof item === "string" && item.trim() === "*"));
1574
+ }
1575
+ function serverlessReference(value) {
1576
+ return /^\$\{[\s\S]+\}$/.test(value.trim());
1577
+ }
1578
+ function yamlLocation(doc, lines, path) {
1579
+ const node = doc.getIn(path, true);
1580
+ const position = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
1581
+ return {
1582
+ line: position.line,
1583
+ column: position.col,
1584
+ path: path.join("."),
1585
+ };
1586
+ }
1587
+ function statementGroups(root) {
1588
+ const groups = [];
1589
+ const seen = new Set();
1590
+ const add = (statements, path) => {
1591
+ if (!Array.isArray(statements) || seen.has(statements))
1592
+ return;
1593
+ seen.add(statements);
1594
+ groups.push({ path, statements });
1595
+ };
1596
+ const provider = object(root.provider);
1597
+ add(provider.iamRoleStatements, ["provider", "iamRoleStatements"]);
1598
+ add(root.iamRoleStatements, ["iamRoleStatements"]);
1599
+ add(object(object(provider.iam).role).statements, ["provider", "iam", "role", "statements"]);
1600
+ add(object(object(root.iam).role).statements, ["iam", "role", "statements"]);
1601
+ if (Array.isArray(provider.iam))
1602
+ add(provider.iam, ["provider", "iam"]);
1603
+ else
1604
+ add(object(provider.iam).statements, ["provider", "iam", "statements"]);
1605
+ return groups;
1606
+ }
1607
+ function parseServerless(content) {
1608
+ if (!looksLikeServerless(content))
1609
+ throw new ScanInputError(serverlessShapeError());
1610
+ const lines = new LineCounter();
1611
+ let documents;
1612
+ try {
1613
+ documents = parseAllDocuments(content, {
1614
+ lineCounter: lines,
1615
+ prettyErrors: false,
1616
+ });
1617
+ }
1618
+ catch {
1619
+ throw new ScanInputError(serverlessShapeError());
1620
+ }
1621
+ const doc = documents.find((item) => item.toJS() != null);
1622
+ if (!doc || doc.errors.length)
1623
+ throw new ScanInputError(serverlessShapeError());
1624
+ let data;
1625
+ try {
1626
+ data = doc.toJS({ maxAliasCount: 0 });
1627
+ }
1628
+ catch {
1629
+ throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
1630
+ }
1631
+ const root = object(data);
1632
+ if (root.service == null ||
1633
+ root.provider == null ||
1634
+ (root.functions == null && root.plugins == null))
1635
+ throw new ScanInputError(serverlessShapeError());
1636
+ const resources = [];
1637
+ let iamCount = 0;
1638
+ for (const group of statementGroups(root)) {
1639
+ group.statements.forEach((statement, index) => {
1640
+ const body = object(statement);
1641
+ const wildcard = effectAllows(body) && actionIsOnlyWildcard(body);
1642
+ const openSourceIp = sourceAddressAllowsEveryone(body);
1643
+ if (!wildcard && !openSourceIp)
1644
+ return;
1645
+ iamCount += 1;
1646
+ const path = [...group.path, index, openSourceIp && !wildcard ? "Condition" : "Action"];
1647
+ resources.push({
1648
+ id: `serverless.iam.${iamCount}`,
1649
+ type: "serverless:iam",
1650
+ value: { wildcard, openSourceIp },
1651
+ locate: () => yamlLocation(doc, lines, path),
1652
+ });
1653
+ });
1654
+ }
1655
+ if (iamCount === 0) {
1656
+ resources.push({
1657
+ id: "serverless.iam",
1658
+ type: "serverless:iam",
1659
+ value: { wildcard: false },
1660
+ locate: () => yamlLocation(doc, lines, ["provider", "iamRoleStatements"]),
1661
+ });
1662
+ }
1663
+ const envHits = [];
1664
+ for (const [key, value] of Object.entries(object(object(root.provider).environment))) {
1665
+ if (secretVariableName(key))
1666
+ envHits.push({ path: ["provider", "environment", key], key, value });
1667
+ }
1668
+ for (const [name, raw] of Object.entries(object(root.functions))) {
1669
+ for (const [key, value] of Object.entries(object(object(raw).environment))) {
1670
+ if (secretVariableName(key))
1671
+ envHits.push({ path: ["functions", name, "environment", key], key, value });
1672
+ }
1673
+ }
1674
+ if (envHits.length === 0) {
1675
+ resources.push({
1676
+ id: "serverless.environment",
1677
+ type: "serverless:env",
1678
+ value: { literal: false },
1679
+ locate: () => yamlLocation(doc, lines, ["provider", "environment"]),
1680
+ });
1681
+ }
1682
+ else {
1683
+ envHits.forEach((hit, index) => {
1684
+ const literal = typeof hit.value === "string" &&
1685
+ hit.value.trim() !== "" &&
1686
+ !serverlessReference(hit.value);
1687
+ resources.push({
1688
+ id: `serverless.env.${index + 1}`,
1689
+ type: "serverless:env",
1690
+ value: { literal },
1691
+ locate: () => yamlLocation(doc, lines, hit.path),
1692
+ });
1693
+ });
1694
+ }
1695
+ return { format: "serverless", resources, warnings: [serverlessWarning] };
1696
+ }
1697
+ function parseIamPolicy(content) {
1698
+ if (!looksLikeIamPolicy(content))
1699
+ throw new ScanInputError(iamShapeError());
1700
+ let root;
1701
+ try {
1702
+ root = object(JSON.parse(content));
1703
+ }
1704
+ catch {
1705
+ throw new ScanInputError(iamShapeError());
1706
+ }
1707
+ const statements = array(root.Statement ?? root.statement);
1708
+ const resources = [];
1709
+ statements.forEach((statement, index) => {
1710
+ const body = object(statement);
1711
+ const wildcard = effectAllows(body) && actionIsOnlyWildcard(body);
1712
+ const openSourceIp = sourceAddressAllowsEveryone(body);
1713
+ if (!wildcard && !openSourceIp)
1714
+ return;
1715
+ const line = iamStatementLine(content, index, openSourceIp && !wildcard ? "Condition" : "Action");
1716
+ resources.push({
1717
+ id: `iam.statement.${index + 1}`,
1718
+ type: "iam:statement",
1719
+ value: { wildcard, openSourceIp },
1720
+ locate: () => lineLocation(line, openSourceIp && !wildcard
1721
+ ? `Statement.${index}.Condition`
1722
+ : `Statement.${index}.Action`),
1723
+ });
1724
+ });
1725
+ if (!resources.length) {
1726
+ resources.push({
1727
+ id: "iam.policy",
1728
+ type: "iam:statement",
1729
+ value: { wildcard: false },
1730
+ locate: () => lineLocation(1, "Statement"),
1731
+ });
1732
+ }
1733
+ return { format: "iam", resources, warnings: [iamPolicyWarning] };
1734
+ }
1735
+ function iamStatementLine(content, index, key = "Action") {
1736
+ const matches = [...content.matchAll(/"Statement"\s*:/g)];
1737
+ const start = matches[0]?.index ?? 0;
1738
+ const region = content.slice(start);
1739
+ const keys = [...region.matchAll(new RegExp(`"${key}"\\s*:`, "g"))];
1740
+ const hit = key === "Action" ? keys[index] : keys.find((item) => item.index != null);
1741
+ if (!hit || hit.index == null)
1742
+ return lineAt(content, start);
1743
+ return lineAt(content, start + hit.index);
1744
+ }
package/dist/iac/rules.js CHANGED
@@ -351,10 +351,40 @@ 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 = [];
357
371
  const checked = new Set();
372
+ const sameFile = collectSameFileLiterals(resources);
373
+ const suppressedSecrets = new WeakMap();
374
+ const moduleSecrets = new WeakMap();
375
+ if (format === "terraform") {
376
+ for (const resource of resources) {
377
+ if (resource.type === "terraform:variable" ||
378
+ resource.type === "terraform:local" ||
379
+ resource.type === "tfvars:assignment")
380
+ continue;
381
+ const suppressed = new Set();
382
+ const referenced = new Set();
383
+ resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced);
384
+ suppressedSecrets.set(resource, suppressed);
385
+ moduleSecrets.set(resource, referenced);
386
+ }
387
+ }
358
388
  for (const resource of resources) {
359
389
  const check = (id, condition, path = []) => {
360
390
  checked.add(id);
@@ -392,8 +422,21 @@ export function resourceFindings(resources, format) {
392
422
  checkPipeline(resource, check);
393
423
  continue;
394
424
  }
425
+ if (format === "serverless") {
426
+ checkServerless(resource, check);
427
+ continue;
428
+ }
429
+ if (format === "iam") {
430
+ check("IAM001", resource.value.wildcard === true, ["Action"]);
431
+ check("NET001", resource.value.openSourceIp === true, ["Condition"]);
432
+ continue;
433
+ }
434
+ if (resource.type === "terraform:local") {
435
+ checkTerraformLiterals(resource.value, "local", check, []);
436
+ continue;
437
+ }
395
438
  if (resource.type === "terraform:module") {
396
- checkTerraformLiterals(resource.value, "module", check, []);
439
+ checkTerraformLiterals(resource.value, "module", check, [], "", moduleSecrets.get(resource));
397
440
  continue;
398
441
  }
399
442
  if (resource.type === "terraform:variable") {
@@ -410,7 +453,8 @@ export function resourceFindings(resources, format) {
410
453
  const credentialName = /(?:password|passwd|secret|api[_-]?key|auth[_-]?token)$/i;
411
454
  check("SEC001", (credentialName.test(key) ||
412
455
  (key === "value" && credentialName.test(String(parent.name)))) &&
413
- literalCredential(value), path);
456
+ literalCredential(value) &&
457
+ !suppressedSecrets.get(resource)?.has(path.join(".")), path);
414
458
  check("SEC002", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/.test(value), path);
415
459
  check("SEC003", /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/.test(value), path);
416
460
  check("NET002", [...value.matchAll(/\bhttp:\/\/[^\s"'<>]+/gi)].some((m) => externalHttp(m[0])), path);
@@ -421,8 +465,15 @@ export function resourceFindings(resources, format) {
421
465
  checkCompose(resource, check);
422
466
  else
423
467
  checkCloud(resource, resources, format, check);
468
+ const referencedSecrets = Object.getOwnPropertyDescriptor(resource.value, "__referencedSecrets")?.value;
469
+ if (Array.isArray(referencedSecrets)) {
470
+ for (const secretPath of referencedSecrets) {
471
+ if (Array.isArray(secretPath))
472
+ check("SEC001", true, secretPath);
473
+ }
474
+ }
424
475
  }
425
- return { findings, checked };
476
+ return { findings: dedupeSecretFindings(findings), checked };
426
477
  }
427
478
  function checkDockerfile(resource, check) {
428
479
  if (resource.type === "dockerfile:user")
@@ -473,6 +524,14 @@ function checkPipeline(resource, check) {
473
524
  else if (resource.type === "pipeline:privileged")
474
525
  check("PIPE003", resource.value.present === true, ["privileged"]);
475
526
  }
527
+ function checkServerless(resource, check) {
528
+ if (resource.type === "serverless:iam") {
529
+ check("IAM001", resource.value.wildcard === true, ["Action"]);
530
+ check("NET001", resource.value.openSourceIp === true, ["Condition"]);
531
+ }
532
+ else if (resource.type === "serverless:env")
533
+ check("SLS001", resource.value.literal === true, ["environment"]);
534
+ }
476
535
  function terraformReference(value) {
477
536
  const trimmed = value.trim();
478
537
  return trimmed.includes("${") || /^var\./.test(trimmed);
@@ -488,27 +547,110 @@ function moduleCidrExempt(name) {
488
547
  normalized === "destination" ||
489
548
  normalized === "ipv6_egress");
490
549
  }
491
- function checkTerraformLiterals(value, mode, check, path, name = "") {
550
+ function checkTerraformLiterals(value, mode, check, path, name = "", referencedSecrets) {
492
551
  if (typeof value === "string" || typeof value === "boolean") {
493
552
  const open = value === "0.0.0.0/0" || value === "::/0";
494
- const exempt = mode === "module" ? moduleCidrExempt(name) : name.toLowerCase() === "egress";
553
+ const exempt = mode === "assignment" ? name.toLowerCase() === "egress" : moduleCidrExempt(name);
495
554
  if (typeof value === "string")
496
555
  check("NET001", open && name !== "" && !exempt, path);
497
556
  if (name === "publicly_accessible")
498
557
  check("DB001", value === true, path);
499
558
  if (mode === "assignment" && typeof value === "string" && secretAssignmentName(name))
500
559
  check("TF001", value.trim() !== "" && !terraformReference(value), path);
560
+ if (mode === "local" && typeof value === "string" && secretAssignmentName(name))
561
+ check("TF002", value.trim() !== "" && !terraformReference(value), path);
562
+ if (mode === "module" && referencedSecrets?.has(path.join(".")))
563
+ check("SEC001", true, path);
501
564
  return;
502
565
  }
503
566
  if (Array.isArray(value)) {
504
- value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name));
567
+ value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name, referencedSecrets));
505
568
  return;
506
569
  }
507
570
  if (value && typeof value === "object") {
508
571
  for (const [key, child] of Object.entries(value))
509
- checkTerraformLiterals(child, mode, check, [...path, key], key);
572
+ checkTerraformLiterals(child, mode, check, [...path, key], key, referencedSecrets);
510
573
  }
511
574
  }
575
+ const exactSameFileRef = /^\$\{(var|local)\.([A-Za-z_][A-Za-z0-9_]*)\}$/;
576
+ function sameFileLiteral(value) {
577
+ if (typeof value === "boolean" || typeof value === "number")
578
+ return value;
579
+ if (typeof value === "string" && !value.includes("${"))
580
+ return value;
581
+ return undefined;
582
+ }
583
+ function collectSameFileLiterals(resources) {
584
+ const vars = new Map();
585
+ const locals = new Map();
586
+ const declaredSecrets = new Set();
587
+ for (const resource of resources) {
588
+ if (resource.type === "terraform:variable") {
589
+ const name = String(resource.value.name ?? "");
590
+ const literal = sameFileLiteral(resource.value.default);
591
+ if (!name || literal === undefined)
592
+ continue;
593
+ vars.set(name, literal);
594
+ if (typeof literal === "string" && literal.trim() !== "" && secretAssignmentName(name))
595
+ declaredSecrets.add(literal);
596
+ }
597
+ if (resource.type === "terraform:local") {
598
+ for (const [name, raw] of Object.entries(resource.value)) {
599
+ const literal = sameFileLiteral(raw);
600
+ if (literal === undefined)
601
+ continue;
602
+ locals.set(name, literal);
603
+ if (typeof literal === "string" && literal.trim() !== "" && secretAssignmentName(name))
604
+ declaredSecrets.add(literal);
605
+ }
606
+ }
607
+ }
608
+ return { vars, locals, declaredSecrets };
609
+ }
610
+ function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecrets) {
611
+ if (typeof value === "string") {
612
+ const match = exactSameFileRef.exec(value.trim());
613
+ if (!match)
614
+ return value;
615
+ const literal = (match[1] === "var" ? sameFile.vars : sameFile.locals).get(match[2]);
616
+ if (literal === undefined)
617
+ return value;
618
+ if (typeof literal === "string" && literal.trim() !== "") {
619
+ const key = [...path].reverse().find((part) => typeof part === "string");
620
+ if (sameFile.declaredSecrets.has(literal))
621
+ suppressed.add(path.join("."));
622
+ else if (typeof key === "string" && secretAssignmentName(String(key)))
623
+ referencedSecrets.add(path.join("."));
624
+ }
625
+ return literal;
626
+ }
627
+ if (Array.isArray(value)) {
628
+ value.forEach((item, index) => {
629
+ value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets);
630
+ });
631
+ return value;
632
+ }
633
+ if (value && typeof value === "object") {
634
+ for (const [key, child] of Object.entries(value)) {
635
+ if (key === "__referencedSecrets")
636
+ continue;
637
+ value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets);
638
+ }
639
+ }
640
+ return value;
641
+ }
642
+ function dedupeSecretFindings(findings) {
643
+ const seen = new Set();
644
+ return findings.filter((finding) => {
645
+ if (finding.ruleId !== "SEC001")
646
+ return true;
647
+ const key = `${finding.ruleId}|${finding.resource}|${finding.location?.path ?? ""}`;
648
+ if (seen.has(key))
649
+ return false;
650
+ seen.add(key);
651
+ return true;
652
+ });
653
+ }
512
654
  function checkAzure(resource, check) {
513
655
  if (resource.type === "azure:exposure")
514
656
  check("AZ001", resource.value.present === true, ["sourceAddressPrefix"]);
package/dist/iac/scan.js CHANGED
@@ -55,6 +55,11 @@ export function scanIac(content, options = {}) {
55
55
  "HCL locations point to resource declarations; property paths identify the affected setting.",
56
56
  ]
57
57
  : []),
58
+ ...(parsed.format === "terraform" || parsed.format === "cloudformation"
59
+ ? [
60
+ "Same-file var, local, and CloudFormation parameter defaults are resolved. Functions, Fn::Sub, and module sources are not.",
61
+ ]
62
+ : []),
58
63
  ],
59
64
  };
60
65
  }
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.14";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.12";
1
+ export const VERSION = "0.6.14";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.12",
3
+ "version": "0.6.14",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"