@skaleagents/swarm 0.6.14 → 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 = "Same-file variable and local literals are resolved. Functions and other expressions are not evaluated.";
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.");
@@ -470,7 +470,7 @@ export function parseIac(content, requested = "auto") {
470
470
  warnings.push("No resources found in the submitted document.");
471
471
  if (format === "cloudformation" &&
472
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.");
473
+ warnings.push("Same-file parameter defaults are resolved. A simple Fn::Sub of those parameters is resolved. Other functions are not evaluated.");
474
474
  else if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
475
475
  warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
476
476
  return { format, resources, warnings };
@@ -1445,6 +1445,36 @@ function cloudFormationIntrinsic(value) {
1445
1445
  return false;
1446
1446
  return Object.keys(value).some((key) => key === "Fn::Sub" || key === "Fn::Join" || key === "Fn::FindInMap" || key.startsWith("Fn::"));
1447
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
+ }
1448
1478
  function resolveCloudFormationRefs(value, parameters) {
1449
1479
  const secretPaths = [];
1450
1480
  const resolved = resolveCloudFormationValue(value, parameters, [], secretPaths);
@@ -1472,6 +1502,15 @@ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1472
1502
  secretPaths.push(path);
1473
1503
  return literal;
1474
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
+ }
1475
1514
  if (cloudFormationIntrinsic(value))
1476
1515
  return value;
1477
1516
  if (Array.isArray(value))
@@ -1487,6 +1526,20 @@ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1487
1526
  }
1488
1527
  return value;
1489
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
+ }
1490
1543
  function terraformLocalBlocks(data) {
1491
1544
  const raw = object(data).locals;
1492
1545
  if (Array.isArray(raw))
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",
@@ -573,6 +580,150 @@ function checkTerraformLiterals(value, mode, check, path, name = "", referencedS
573
580
  }
574
581
  }
575
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
+ }
576
727
  function sameFileLiteral(value) {
577
728
  if (typeof value === "boolean" || typeof value === "number")
578
729
  return value;
@@ -610,19 +761,25 @@ function collectSameFileLiterals(resources) {
610
761
  function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecrets) {
611
762
  if (typeof value === "string") {
612
763
  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("."));
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;
624
771
  }
625
- return literal;
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;
626
783
  }
627
784
  if (Array.isArray(value)) {
628
785
  value.forEach((item, index) => {
@@ -705,6 +862,39 @@ function checkCompose(resource, check) {
705
862
  service.mem_reservation == null, ["mem_limit"]);
706
863
  check("NET001", array(service.ports).some(composePortIsPublic), ["ports"]);
707
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
+ }
708
898
  function checkCloud(resource, resources, format, check) {
709
899
  const tf = format === "terraform";
710
900
  const pulumi = format === "pulumi";
@@ -727,31 +917,30 @@ function checkCloud(resource, resources, format, check) {
727
917
  isIngress) {
728
918
  check("NET001", array(child).some((cidr) => cidr === "0.0.0.0/0" || cidr === "::/0"), [...prefix, ...at]);
729
919
  }
730
- if (/^(?:Action|Principal|actions|principals)$/.test(key) &&
731
- (parent.Effect === "Allow" || parent.effect === "Allow")) {
732
- const values = typeof child === "object" && !Array.isArray(child)
733
- ? Object.values(object(child)).flatMap(array)
734
- : array(child);
735
- check("IAM001", values.some((item) => typeof item === "string" && item.includes("*")), [...prefix, ...at]);
736
- }
737
- if (/^(?:policy|assume_role_policy|PolicyDocument)$/.test(key) &&
738
- typeof child === "string" &&
739
- child.trimStart().startsWith("{")) {
740
- try {
741
- const document = JSON.parse(child);
742
- for (const statement of array(object(document).Statement)) {
743
- const s = object(statement);
744
- check("IAM001", s.Effect === "Allow" &&
745
- [
746
- ...array(s.Action),
747
- ...array(s.Principal),
748
- ...Object.values(object(s.Principal)).flatMap(array),
749
- ].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;
750
939
  }
751
940
  }
752
- catch {
753
- /* Nonliteral policies are covered by the expression warning. */
754
- }
941
+ const document = encoded ?? parsed;
942
+ if (document !== undefined)
943
+ checkInlinePolicy(document, check, [...prefix, ...at]);
755
944
  }
756
945
  if (/^(?:instance_type|machine_type|vm_size|InstanceType|DBInstanceClass|instance_class)$/.test(key))
757
946
  check("COST001", typeof child === "string", [...prefix, ...at]);
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.14";
1
+ export declare const VERSION = "0.6.15";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.14";
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.14",
3
+ "version": "0.6.15",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"