@skaleagents/swarm 0.6.13 → 0.6.15

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.
@@ -33,5 +33,6 @@ 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 terraformJsonencodeValue(expression: string): unknown | undefined;
36
37
  export declare function looksLikeServerless(content: string): boolean;
37
38
  export declare function looksLikeIamPolicy(content: string): boolean;
package/dist/iac/parse.js CHANGED
@@ -139,7 +139,7 @@ export function looksLikePulumiYaml(content) {
139
139
  return (/^\s*resources\s*:/m.test(content) &&
140
140
  /^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
141
141
  }
142
- const terraformExpressionWarning = "Terraform expressions are not evaluated. Findings use literal values and declared settings.";
142
+ const terraformExpressionWarning = "Same-file variable and local literals are resolved. A string of those references and format of known literals are resolved. Other functions and expressions are not evaluated.";
143
143
  export function parseIac(content, requested = "auto") {
144
144
  if (!content.trim())
145
145
  throw new ScanInputError("Content must not be empty or whitespace.");
@@ -412,11 +412,12 @@ export function parseIac(content, requested = "auto") {
412
412
  Array.isArray(root.Resources) ||
413
413
  typeof root.Resources !== "object")
414
414
  throw new ScanInputError("CloudFormation requires a Resources mapping.");
415
+ const parameters = cloudFormationParameterLiterals(root);
415
416
  for (const [name, raw] of Object.entries(object(root.Resources))) {
416
417
  const value = object(raw);
417
418
  if (typeof value.Type !== "string")
418
419
  throw new ScanInputError("Each CloudFormation resource requires a Type.");
419
- add(name, value.Type, value, ["Resources", name]);
420
+ add(name, value.Type, resolveCloudFormationRefs(value, parameters), ["Resources", name]);
420
421
  }
421
422
  }
422
423
  else if (format === "terraform") {
@@ -467,7 +468,10 @@ export function parseIac(content, requested = "auto") {
467
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.");
468
469
  if (!resources.length)
469
470
  warnings.push("No resources found in the submitted document.");
470
- 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. A simple Fn::Sub of those parameters is resolved. Other functions are not evaluated.");
474
+ else if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
471
475
  warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
472
476
  return { format, resources, warnings };
473
477
  }
@@ -1403,6 +1407,139 @@ function parseAzure(content, format) {
1403
1407
  warnings: [azureExpressionWarning],
1404
1408
  };
1405
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 cloudFormationSubTemplate(value) {
1449
+ if (!value || typeof value !== "object" || Array.isArray(value))
1450
+ return undefined;
1451
+ const entries = Object.entries(value).filter(([key]) => key !== "__referencedSecrets");
1452
+ if (entries.length !== 1)
1453
+ return undefined;
1454
+ const [key, template] = entries[0];
1455
+ if (key !== "Fn::Sub" || typeof template !== "string")
1456
+ return undefined;
1457
+ return template;
1458
+ }
1459
+ function substituteKnownParameters(template, parameters) {
1460
+ let unresolved = false;
1461
+ let secret = false;
1462
+ const text = template.replace(/\$\{(!?)([^}]*)\}/g, (full, literalMark, rawName) => {
1463
+ if (literalMark === "!")
1464
+ return `\${${rawName}}`;
1465
+ const name = rawName.trim();
1466
+ if (!parameters.defaults.has(name)) {
1467
+ unresolved = true;
1468
+ return full;
1469
+ }
1470
+ if (parameters.secretNames.has(name))
1471
+ secret = true;
1472
+ return String(parameters.defaults.get(name));
1473
+ });
1474
+ if (unresolved)
1475
+ return undefined;
1476
+ return { text, secret };
1477
+ }
1478
+ function resolveCloudFormationRefs(value, parameters) {
1479
+ const secretPaths = [];
1480
+ const resolved = resolveCloudFormationValue(value, parameters, [], secretPaths);
1481
+ if (secretPaths.length > 0 &&
1482
+ resolved &&
1483
+ typeof resolved === "object" &&
1484
+ !Array.isArray(resolved)) {
1485
+ Object.defineProperty(resolved, "__referencedSecrets", {
1486
+ enumerable: false,
1487
+ configurable: true,
1488
+ value: secretPaths,
1489
+ });
1490
+ }
1491
+ return object(resolved);
1492
+ }
1493
+ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1494
+ const ref = cloudFormationRefName(value);
1495
+ if (ref) {
1496
+ if (!parameters.defaults.has(ref))
1497
+ return value;
1498
+ const literal = parameters.defaults.get(ref);
1499
+ if (parameters.secretNames.has(ref) &&
1500
+ typeof literal === "string" &&
1501
+ literal.trim() !== "")
1502
+ secretPaths.push(path);
1503
+ return literal;
1504
+ }
1505
+ const subTemplate = cloudFormationSubTemplate(value);
1506
+ if (subTemplate !== undefined) {
1507
+ const substituted = substituteKnownParameters(subTemplate, parameters);
1508
+ if (!substituted)
1509
+ return value;
1510
+ if (substituted.secret)
1511
+ secretPaths.push(path);
1512
+ return substituted.text;
1513
+ }
1514
+ if (cloudFormationIntrinsic(value))
1515
+ return value;
1516
+ if (Array.isArray(value))
1517
+ return value.map((item, index) => resolveCloudFormationValue(item, parameters, [...path, index], secretPaths));
1518
+ if (value && typeof value === "object") {
1519
+ const out = {};
1520
+ for (const [key, child] of Object.entries(value)) {
1521
+ if (key === "__referencedSecrets")
1522
+ continue;
1523
+ out[key] = resolveCloudFormationValue(child, parameters, [...path, key], secretPaths);
1524
+ }
1525
+ return out;
1526
+ }
1527
+ return value;
1528
+ }
1529
+ export function terraformJsonencodeValue(expression) {
1530
+ const match = /^\$\{jsonencode\(([\s\S]*)\)\}$/.exec(expression.trim());
1531
+ if (!match)
1532
+ return undefined;
1533
+ try {
1534
+ const [parsed, error] = hcl.parseToObject(`value = ${match[1]}\n`);
1535
+ if (error || !parsed)
1536
+ return undefined;
1537
+ return object(parsed).value;
1538
+ }
1539
+ catch {
1540
+ return undefined;
1541
+ }
1542
+ }
1406
1543
  function terraformLocalBlocks(data) {
1407
1544
  const raw = object(data).locals;
1408
1545
  if (Array.isArray(raw))
@@ -1443,8 +1580,8 @@ export function looksLikeIamPolicy(content) {
1443
1580
  const statement = root.Statement ?? root.statement;
1444
1581
  return Array.isArray(statement);
1445
1582
  }
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.";
1583
+ const serverlessWarning = "The Serverless Framework config is not executed. Other condition operators are not evaluated.";
1584
+ const iamPolicyWarning = "The IAM policy is not executed. Other condition operators are not evaluated.";
1448
1585
  function serverlessShapeError() {
1449
1586
  return "Serverless Framework config requires top-level service and provider, plus functions or plugins.";
1450
1587
  }
@@ -1457,6 +1594,29 @@ function effectAllows(body) {
1457
1594
  return true;
1458
1595
  return String(effect).trim().toLowerCase() === "allow";
1459
1596
  }
1597
+ const openSourceAddresses = new Set(["0.0.0.0/0", "::/0", "*"]);
1598
+ function conditionValueIsOpen(value) {
1599
+ if (typeof value === "string")
1600
+ return openSourceAddresses.has(value.trim());
1601
+ if (Array.isArray(value))
1602
+ return value.some((item) => conditionValueIsOpen(item));
1603
+ return false;
1604
+ }
1605
+ function sourceAddressAllowsEveryone(body) {
1606
+ if (!effectAllows(body))
1607
+ return false;
1608
+ const condition = object(body.Condition ?? body.condition);
1609
+ let ipAddress;
1610
+ for (const [key, value] of Object.entries(condition)) {
1611
+ if (key.toLowerCase() === "ipaddress") {
1612
+ ipAddress = value;
1613
+ break;
1614
+ }
1615
+ }
1616
+ if (!ipAddress || typeof ipAddress !== "object" || Array.isArray(ipAddress))
1617
+ return false;
1618
+ return Object.values(object(ipAddress)).some((value) => conditionValueIsOpen(value));
1619
+ }
1460
1620
  function actionIsOnlyWildcard(body) {
1461
1621
  const action = body.Action ?? body.action ?? body.actions ?? body.Actions;
1462
1622
  if (typeof action === "string")
@@ -1531,14 +1691,16 @@ function parseServerless(content) {
1531
1691
  for (const group of statementGroups(root)) {
1532
1692
  group.statements.forEach((statement, index) => {
1533
1693
  const body = object(statement);
1534
- if (!effectAllows(body) || !actionIsOnlyWildcard(body))
1694
+ const wildcard = effectAllows(body) && actionIsOnlyWildcard(body);
1695
+ const openSourceIp = sourceAddressAllowsEveryone(body);
1696
+ if (!wildcard && !openSourceIp)
1535
1697
  return;
1536
1698
  iamCount += 1;
1537
- const path = [...group.path, index, "Action"];
1699
+ const path = [...group.path, index, openSourceIp && !wildcard ? "Condition" : "Action"];
1538
1700
  resources.push({
1539
1701
  id: `serverless.iam.${iamCount}`,
1540
1702
  type: "serverless:iam",
1541
- value: { wildcard: true },
1703
+ value: { wildcard, openSourceIp },
1542
1704
  locate: () => yamlLocation(doc, lines, path),
1543
1705
  });
1544
1706
  });
@@ -1599,14 +1761,18 @@ function parseIamPolicy(content) {
1599
1761
  const resources = [];
1600
1762
  statements.forEach((statement, index) => {
1601
1763
  const body = object(statement);
1602
- if (!effectAllows(body) || !actionIsOnlyWildcard(body))
1764
+ const wildcard = effectAllows(body) && actionIsOnlyWildcard(body);
1765
+ const openSourceIp = sourceAddressAllowsEveryone(body);
1766
+ if (!wildcard && !openSourceIp)
1603
1767
  return;
1604
- const line = iamStatementLine(content, index);
1768
+ const line = iamStatementLine(content, index, openSourceIp && !wildcard ? "Condition" : "Action");
1605
1769
  resources.push({
1606
1770
  id: `iam.statement.${index + 1}`,
1607
1771
  type: "iam:statement",
1608
- value: { wildcard: true },
1609
- locate: () => lineLocation(line, `Statement.${index}.Action`),
1772
+ value: { wildcard, openSourceIp },
1773
+ locate: () => lineLocation(line, openSourceIp && !wildcard
1774
+ ? `Statement.${index}.Condition`
1775
+ : `Statement.${index}.Action`),
1610
1776
  });
1611
1777
  });
1612
1778
  if (!resources.length) {
@@ -1619,13 +1785,13 @@ function parseIamPolicy(content) {
1619
1785
  }
1620
1786
  return { format: "iam", resources, warnings: [iamPolicyWarning] };
1621
1787
  }
1622
- function iamStatementLine(content, index) {
1788
+ function iamStatementLine(content, index, key = "Action") {
1623
1789
  const matches = [...content.matchAll(/"Statement"\s*:/g)];
1624
1790
  const start = matches[0]?.index ?? 0;
1625
1791
  const region = content.slice(start);
1626
- const actions = [...region.matchAll(/"Action"\s*:/g)];
1627
- const action = actions[index];
1628
- if (!action || action.index == null)
1792
+ const keys = [...region.matchAll(new RegExp(`"${key}"\\s*:`, "g"))];
1793
+ const hit = key === "Action" ? keys[index] : keys.find((item) => item.index != null);
1794
+ if (!hit || hit.index == null)
1629
1795
  return lineAt(content, start);
1630
- return lineAt(content, start + action.index);
1796
+ return lineAt(content, start + hit.index);
1631
1797
  }
package/dist/iac/rules.js CHANGED
@@ -1,4 +1,4 @@
1
- import { array, object } from "./parse.js";
1
+ import { array, object, terraformJsonencodeValue } from "./parse.js";
2
2
  import { externalHttp, literalCredential } from "../review.js";
3
3
  export const rules = {
4
4
  SEC001: {
@@ -40,8 +40,15 @@ export const rules = {
40
40
  category: "security",
41
41
  severity: "high",
42
42
  title: "Wildcard permission detected",
43
- detail: "An Allow statement grants wildcard actions or principals.",
44
- remediation: "List the required actions and trusted principals explicitly, with resource and condition restrictions.",
43
+ detail: "An Allow statement grants a wildcard action.",
44
+ remediation: "List the required actions explicitly, with resource and condition restrictions.",
45
+ },
46
+ IAM002: {
47
+ category: "security",
48
+ severity: "high",
49
+ title: "Policy allows any principal",
50
+ detail: "An Allow statement sets Principal to any AWS principal.",
51
+ remediation: "Name the trusted principals explicitly instead of allowing any principal.",
45
52
  },
46
53
  DATA001: {
47
54
  category: "security",
@@ -369,6 +376,22 @@ export const rules = {
369
376
  export function resourceFindings(resources, format) {
370
377
  const findings = [];
371
378
  const checked = new Set();
379
+ const sameFile = collectSameFileLiterals(resources);
380
+ const suppressedSecrets = new WeakMap();
381
+ const moduleSecrets = new WeakMap();
382
+ if (format === "terraform") {
383
+ for (const resource of resources) {
384
+ if (resource.type === "terraform:variable" ||
385
+ resource.type === "terraform:local" ||
386
+ resource.type === "tfvars:assignment")
387
+ continue;
388
+ const suppressed = new Set();
389
+ const referenced = new Set();
390
+ resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced);
391
+ suppressedSecrets.set(resource, suppressed);
392
+ moduleSecrets.set(resource, referenced);
393
+ }
394
+ }
372
395
  for (const resource of resources) {
373
396
  const check = (id, condition, path = []) => {
374
397
  checked.add(id);
@@ -412,6 +435,7 @@ export function resourceFindings(resources, format) {
412
435
  }
413
436
  if (format === "iam") {
414
437
  check("IAM001", resource.value.wildcard === true, ["Action"]);
438
+ check("NET001", resource.value.openSourceIp === true, ["Condition"]);
415
439
  continue;
416
440
  }
417
441
  if (resource.type === "terraform:local") {
@@ -419,7 +443,7 @@ export function resourceFindings(resources, format) {
419
443
  continue;
420
444
  }
421
445
  if (resource.type === "terraform:module") {
422
- checkTerraformLiterals(resource.value, "module", check, []);
446
+ checkTerraformLiterals(resource.value, "module", check, [], "", moduleSecrets.get(resource));
423
447
  continue;
424
448
  }
425
449
  if (resource.type === "terraform:variable") {
@@ -436,7 +460,8 @@ export function resourceFindings(resources, format) {
436
460
  const credentialName = /(?:password|passwd|secret|api[_-]?key|auth[_-]?token)$/i;
437
461
  check("SEC001", (credentialName.test(key) ||
438
462
  (key === "value" && credentialName.test(String(parent.name)))) &&
439
- literalCredential(value), path);
463
+ literalCredential(value) &&
464
+ !suppressedSecrets.get(resource)?.has(path.join(".")), path);
440
465
  check("SEC002", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/.test(value), path);
441
466
  check("SEC003", /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/.test(value), path);
442
467
  check("NET002", [...value.matchAll(/\bhttp:\/\/[^\s"'<>]+/gi)].some((m) => externalHttp(m[0])), path);
@@ -447,8 +472,15 @@ export function resourceFindings(resources, format) {
447
472
  checkCompose(resource, check);
448
473
  else
449
474
  checkCloud(resource, resources, format, check);
475
+ const referencedSecrets = Object.getOwnPropertyDescriptor(resource.value, "__referencedSecrets")?.value;
476
+ if (Array.isArray(referencedSecrets)) {
477
+ for (const secretPath of referencedSecrets) {
478
+ if (Array.isArray(secretPath))
479
+ check("SEC001", true, secretPath);
480
+ }
481
+ }
450
482
  }
451
- return { findings, checked };
483
+ return { findings: dedupeSecretFindings(findings), checked };
452
484
  }
453
485
  function checkDockerfile(resource, check) {
454
486
  if (resource.type === "dockerfile:user")
@@ -500,8 +532,10 @@ function checkPipeline(resource, check) {
500
532
  check("PIPE003", resource.value.present === true, ["privileged"]);
501
533
  }
502
534
  function checkServerless(resource, check) {
503
- if (resource.type === "serverless:iam")
535
+ if (resource.type === "serverless:iam") {
504
536
  check("IAM001", resource.value.wildcard === true, ["Action"]);
537
+ check("NET001", resource.value.openSourceIp === true, ["Condition"]);
538
+ }
505
539
  else if (resource.type === "serverless:env")
506
540
  check("SLS001", resource.value.literal === true, ["environment"]);
507
541
  }
@@ -520,7 +554,7 @@ function moduleCidrExempt(name) {
520
554
  normalized === "destination" ||
521
555
  normalized === "ipv6_egress");
522
556
  }
523
- function checkTerraformLiterals(value, mode, check, path, name = "") {
557
+ function checkTerraformLiterals(value, mode, check, path, name = "", referencedSecrets) {
524
558
  if (typeof value === "string" || typeof value === "boolean") {
525
559
  const open = value === "0.0.0.0/0" || value === "::/0";
526
560
  const exempt = mode === "assignment" ? name.toLowerCase() === "egress" : moduleCidrExempt(name);
@@ -532,16 +566,247 @@ function checkTerraformLiterals(value, mode, check, path, name = "") {
532
566
  check("TF001", value.trim() !== "" && !terraformReference(value), path);
533
567
  if (mode === "local" && typeof value === "string" && secretAssignmentName(name))
534
568
  check("TF002", value.trim() !== "" && !terraformReference(value), path);
569
+ if (mode === "module" && referencedSecrets?.has(path.join(".")))
570
+ check("SEC001", true, path);
535
571
  return;
536
572
  }
537
573
  if (Array.isArray(value)) {
538
- value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name));
574
+ value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name, referencedSecrets));
539
575
  return;
540
576
  }
541
577
  if (value && typeof value === "object") {
542
578
  for (const [key, child] of Object.entries(value))
543
- checkTerraformLiterals(child, mode, check, [...path, key], key);
579
+ checkTerraformLiterals(child, mode, check, [...path, key], key, referencedSecrets);
580
+ }
581
+ }
582
+ const exactSameFileRef = /^\$\{(var|local)\.([A-Za-z_][A-Za-z0-9_]*)\}$/;
583
+ const interpolationPiece = /\$\{(var|local)\.([A-Za-z_][A-Za-z0-9_]*)\}/g;
584
+ const interpolationOnly = /^(\$\{(?:var|local)\.[A-Za-z_][A-Za-z0-9_]*\})+$/;
585
+ function sameFileText(kind, name, sameFile) {
586
+ const literal = (kind === "var" ? sameFile.vars : sameFile.locals).get(name);
587
+ if (typeof literal === "string" || typeof literal === "number" || typeof literal === "boolean")
588
+ return String(literal);
589
+ return undefined;
590
+ }
591
+ function resolveInterpolationString(value, sameFile) {
592
+ const trimmed = value.trim();
593
+ if (!interpolationOnly.test(trimmed))
594
+ return undefined;
595
+ let count = 0;
596
+ let failed = false;
597
+ const text = trimmed.replace(interpolationPiece, (_full, kind, name) => {
598
+ count += 1;
599
+ const part = sameFileText(kind, name, sameFile);
600
+ if (part === undefined) {
601
+ failed = true;
602
+ return "";
603
+ }
604
+ return part;
605
+ });
606
+ if (failed || count < 2)
607
+ return undefined;
608
+ return text;
609
+ }
610
+ function splitCallArguments(source) {
611
+ const args = [];
612
+ let current = "";
613
+ let quote = null;
614
+ let depth = 0;
615
+ for (let index = 0; index < source.length; index += 1) {
616
+ const char = source[index];
617
+ if (quote) {
618
+ current += char;
619
+ if (char === "\\" && index + 1 < source.length) {
620
+ current += source[index + 1];
621
+ index += 1;
622
+ continue;
623
+ }
624
+ if (char === quote)
625
+ quote = null;
626
+ continue;
627
+ }
628
+ if (char === '"' || char === "'") {
629
+ quote = char;
630
+ current += char;
631
+ continue;
632
+ }
633
+ if (char === "(" || char === "[" || char === "{") {
634
+ depth += 1;
635
+ current += char;
636
+ continue;
637
+ }
638
+ if (char === ")" || char === "]" || char === "}") {
639
+ if (depth === 0)
640
+ return undefined;
641
+ depth -= 1;
642
+ current += char;
643
+ continue;
644
+ }
645
+ if (char === "," && depth === 0) {
646
+ args.push(current.trim());
647
+ current = "";
648
+ continue;
649
+ }
650
+ current += char;
651
+ }
652
+ if (quote || depth !== 0)
653
+ return undefined;
654
+ if (current.trim() !== "")
655
+ args.push(current.trim());
656
+ return args;
657
+ }
658
+ function unquoteHcl(token) {
659
+ if (token.length < 2)
660
+ return undefined;
661
+ const quote = token[0];
662
+ if ((quote !== '"' && quote !== "'") || token[token.length - 1] !== quote)
663
+ return undefined;
664
+ const body = token.slice(1, -1);
665
+ return quote === '"'
666
+ ? body.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
667
+ : body.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
668
+ }
669
+ function resolveFormatArgument(token, sameFile) {
670
+ const literal = unquoteHcl(token);
671
+ if (literal !== undefined)
672
+ return literal;
673
+ if (/^-?\d+(?:\.\d+)?$/.test(token) || token === "true" || token === "false")
674
+ return token;
675
+ const ref = /^(var|local)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(token);
676
+ if (!ref)
677
+ return undefined;
678
+ return sameFileText(ref[1], ref[2], sameFile);
679
+ }
680
+ function applyPercentS(format, args) {
681
+ let index = 0;
682
+ let text = "";
683
+ for (let cursor = 0; cursor < format.length; cursor += 1) {
684
+ if (format[cursor] !== "%") {
685
+ text += format[cursor];
686
+ continue;
687
+ }
688
+ const verb = format[cursor + 1];
689
+ if (verb === "%") {
690
+ text += "%";
691
+ cursor += 1;
692
+ continue;
693
+ }
694
+ if (verb !== "s" || index >= args.length)
695
+ return undefined;
696
+ text += args[index];
697
+ index += 1;
698
+ cursor += 1;
699
+ }
700
+ return text;
701
+ }
702
+ function resolveFormatCall(value, sameFile) {
703
+ const wrapped = /^\$\{format\(([\s\S]*)\)\}$/.exec(value.trim());
704
+ if (!wrapped)
705
+ return undefined;
706
+ const args = splitCallArguments(wrapped[1]);
707
+ if (!args || args.length === 0)
708
+ return undefined;
709
+ const resolved = [];
710
+ for (const arg of args) {
711
+ const part = resolveFormatArgument(arg, sameFile);
712
+ if (part === undefined)
713
+ return undefined;
714
+ resolved.push(part);
715
+ }
716
+ return applyPercentS(resolved[0], resolved.slice(1));
717
+ }
718
+ function noteResolvedLiteral(literal, path, sameFile, suppressed, referencedSecrets) {
719
+ if (literal.trim() === "")
720
+ return;
721
+ const key = [...path].reverse().find((part) => typeof part === "string");
722
+ if (sameFile.declaredSecrets.has(literal))
723
+ suppressed.add(path.join("."));
724
+ else if (typeof key === "string" && secretAssignmentName(key))
725
+ referencedSecrets.add(path.join("."));
726
+ }
727
+ function sameFileLiteral(value) {
728
+ if (typeof value === "boolean" || typeof value === "number")
729
+ return value;
730
+ if (typeof value === "string" && !value.includes("${"))
731
+ return value;
732
+ return undefined;
733
+ }
734
+ function collectSameFileLiterals(resources) {
735
+ const vars = new Map();
736
+ const locals = new Map();
737
+ const declaredSecrets = new Set();
738
+ for (const resource of resources) {
739
+ if (resource.type === "terraform:variable") {
740
+ const name = String(resource.value.name ?? "");
741
+ const literal = sameFileLiteral(resource.value.default);
742
+ if (!name || literal === undefined)
743
+ continue;
744
+ vars.set(name, literal);
745
+ if (typeof literal === "string" && literal.trim() !== "" && secretAssignmentName(name))
746
+ declaredSecrets.add(literal);
747
+ }
748
+ if (resource.type === "terraform:local") {
749
+ for (const [name, raw] of Object.entries(resource.value)) {
750
+ const literal = sameFileLiteral(raw);
751
+ if (literal === undefined)
752
+ continue;
753
+ locals.set(name, literal);
754
+ if (typeof literal === "string" && literal.trim() !== "" && secretAssignmentName(name))
755
+ declaredSecrets.add(literal);
756
+ }
757
+ }
544
758
  }
759
+ return { vars, locals, declaredSecrets };
760
+ }
761
+ function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecrets) {
762
+ if (typeof value === "string") {
763
+ const match = exactSameFileRef.exec(value.trim());
764
+ if (match) {
765
+ const literal = (match[1] === "var" ? sameFile.vars : sameFile.locals).get(match[2]);
766
+ if (literal === undefined)
767
+ return value;
768
+ if (typeof literal === "string")
769
+ noteResolvedLiteral(literal, path, sameFile, suppressed, referencedSecrets);
770
+ return literal;
771
+ }
772
+ const concatenated = resolveInterpolationString(value, sameFile);
773
+ if (concatenated !== undefined) {
774
+ noteResolvedLiteral(concatenated, path, sameFile, suppressed, referencedSecrets);
775
+ return concatenated;
776
+ }
777
+ const formatted = resolveFormatCall(value, sameFile);
778
+ if (formatted !== undefined) {
779
+ noteResolvedLiteral(formatted, path, sameFile, suppressed, referencedSecrets);
780
+ return formatted;
781
+ }
782
+ return value;
783
+ }
784
+ if (Array.isArray(value)) {
785
+ value.forEach((item, index) => {
786
+ value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets);
787
+ });
788
+ return value;
789
+ }
790
+ if (value && typeof value === "object") {
791
+ for (const [key, child] of Object.entries(value)) {
792
+ if (key === "__referencedSecrets")
793
+ continue;
794
+ value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets);
795
+ }
796
+ }
797
+ return value;
798
+ }
799
+ function dedupeSecretFindings(findings) {
800
+ const seen = new Set();
801
+ return findings.filter((finding) => {
802
+ if (finding.ruleId !== "SEC001")
803
+ return true;
804
+ const key = `${finding.ruleId}|${finding.resource}|${finding.location?.path ?? ""}`;
805
+ if (seen.has(key))
806
+ return false;
807
+ seen.add(key);
808
+ return true;
809
+ });
545
810
  }
546
811
  function checkAzure(resource, check) {
547
812
  if (resource.type === "azure:exposure")
@@ -597,6 +862,39 @@ function checkCompose(resource, check) {
597
862
  service.mem_reservation == null, ["mem_limit"]);
598
863
  check("NET001", array(service.ports).some(composePortIsPublic), ["ports"]);
599
864
  }
865
+ function statementEffectAllows(body) {
866
+ const effect = body.Effect ?? body.effect;
867
+ if (effect == null || String(effect).trim() === "")
868
+ return true;
869
+ return String(effect).trim().toLowerCase() === "allow";
870
+ }
871
+ function principalAllowsAnyone(value) {
872
+ if (typeof value === "string")
873
+ return value.trim() === "*";
874
+ if (Array.isArray(value))
875
+ return value.some((item) => principalAllowsAnyone(item));
876
+ if (value && typeof value === "object")
877
+ return Object.values(value).some((item) => principalAllowsAnyone(item));
878
+ return false;
879
+ }
880
+ function actionIncludesWildcard(value) {
881
+ return array(value).some((item) => typeof item === "string" && item.includes("*"));
882
+ }
883
+ function checkInlinePolicy(document, check, path) {
884
+ const root = object(document);
885
+ const statements = root.Statement ?? root.statement;
886
+ if (!Array.isArray(statements) && (statements == null || typeof statements !== "object"))
887
+ return;
888
+ for (const statement of array(statements)) {
889
+ const body = object(statement);
890
+ if (!statementEffectAllows(body))
891
+ continue;
892
+ check("IAM001", actionIncludesWildcard(body.Action ?? body.action ?? body.Actions ?? body.actions), path);
893
+ const principal = body.Principal ?? body.principal ?? body.Principals ?? body.principals;
894
+ if (principal !== undefined)
895
+ check("IAM002", principalAllowsAnyone(principal), path);
896
+ }
897
+ }
600
898
  function checkCloud(resource, resources, format, check) {
601
899
  const tf = format === "terraform";
602
900
  const pulumi = format === "pulumi";
@@ -619,31 +917,30 @@ function checkCloud(resource, resources, format, check) {
619
917
  isIngress) {
620
918
  check("NET001", array(child).some((cidr) => cidr === "0.0.0.0/0" || cidr === "::/0"), [...prefix, ...at]);
621
919
  }
622
- if (/^(?:Action|Principal|actions|principals)$/.test(key) &&
623
- (parent.Effect === "Allow" || parent.effect === "Allow")) {
624
- const values = typeof child === "object" && !Array.isArray(child)
625
- ? Object.values(object(child)).flatMap(array)
626
- : array(child);
627
- check("IAM001", values.some((item) => typeof item === "string" && item.includes("*")), [...prefix, ...at]);
628
- }
629
- if (/^(?:policy|assume_role_policy|PolicyDocument)$/.test(key) &&
630
- typeof child === "string" &&
631
- child.trimStart().startsWith("{")) {
632
- try {
633
- const document = JSON.parse(child);
634
- for (const statement of array(object(document).Statement)) {
635
- const s = object(statement);
636
- check("IAM001", s.Effect === "Allow" &&
637
- [
638
- ...array(s.Action),
639
- ...array(s.Principal),
640
- ...Object.values(object(s.Principal)).flatMap(array),
641
- ].some((v) => typeof v === "string" && v.includes("*")), [...prefix, ...at]);
920
+ if (/^(?:Action|actions)$/.test(key) && statementEffectAllows(parent)) {
921
+ check("IAM001", actionIncludesWildcard(child), [...prefix, ...at]);
922
+ }
923
+ if (/^(?:Principal|principals)$/.test(key) && statementEffectAllows(parent)) {
924
+ check("IAM002", principalAllowsAnyone(child), [...prefix, ...at]);
925
+ }
926
+ if (typeof child === "string") {
927
+ const encoded = child.trim().startsWith("${jsonencode(")
928
+ ? terraformJsonencodeValue(child)
929
+ : undefined;
930
+ let parsed;
931
+ if (encoded === undefined &&
932
+ /^(?:policy|assume_role_policy|PolicyDocument)$/.test(key) &&
933
+ child.trimStart().startsWith("{")) {
934
+ try {
935
+ parsed = JSON.parse(child);
936
+ }
937
+ catch {
938
+ parsed = undefined;
642
939
  }
643
940
  }
644
- catch {
645
- /* Nonliteral policies are covered by the expression warning. */
646
- }
941
+ const document = encoded ?? parsed;
942
+ if (document !== undefined)
943
+ checkInlinePolicy(document, check, [...prefix, ...at]);
647
944
  }
648
945
  if (/^(?:instance_type|machine_type|vm_size|InstanceType|DBInstanceClass|instance_class)$/.test(key))
649
946
  check("COST001", typeof child === "string", [...prefix, ...at]);
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/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.13";
1
+ export declare const VERSION = "0.6.15";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.13";
1
+ export const VERSION = "0.6.15";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"