@skaleagents/swarm 0.6.14 → 0.6.16

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" | "serverless" | "iam";
1
+ export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github" | "helm" | "ansible" | "bicep" | "arm" | "gitlab" | "azure-pipelines" | "cloudbuild" | "tfvars" | "serverless" | "iam" | "package";
2
2
  export type Path = (string | number)[];
3
3
  export type Location = {
4
4
  line: number;
@@ -32,6 +32,14 @@ export declare function looksLikeTerraformHcl(content: string): boolean;
32
32
  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
- export declare function parseIac(content: string, requested?: IacFormat | "auto"): ParsedIac;
35
+ export type CloudFormationTables = {
36
+ defaults: Map<string, string | boolean | number>;
37
+ secretNames: Set<string>;
38
+ mappings: Map<string, Record<string, Record<string, string | number | boolean>>>;
39
+ };
40
+ export declare function parseIac(content: string, requested?: IacFormat | "auto", shared?: CloudFormationTables): ParsedIac;
41
+ export declare function readCloudFormationTables(content: string): CloudFormationTables;
42
+ export declare function terraformJsonencodeValue(expression: string): unknown | undefined;
36
43
  export declare function looksLikeServerless(content: string): boolean;
44
+ export declare function looksLikePackage(content: string): boolean;
37
45
  export declare function looksLikeIamPolicy(content: string): boolean;
package/dist/iac/parse.js CHANGED
@@ -32,6 +32,8 @@ export function looksLikeIac(content) {
32
32
  return true;
33
33
  if (looksLikeIamPolicy(content))
34
34
  return true;
35
+ if (looksLikePackage(content))
36
+ return true;
35
37
  }
36
38
  catch {
37
39
  /* Other format detection still applies to incomplete input. */
@@ -139,10 +141,12 @@ export function looksLikePulumiYaml(content) {
139
141
  return (/^\s*resources\s*:/m.test(content) &&
140
142
  /^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
141
143
  }
142
- const terraformExpressionWarning = "Same-file variable and local literals are resolved. Functions and other expressions are not evaluated.";
143
- export function parseIac(content, requested = "auto") {
144
+ const terraformExpressionWarning = "Variable and local literals from the submitted files are resolved. A string of those references, format of known literals, and join of known literals are resolved. Other functions and expressions are not evaluated.";
145
+ export function parseIac(content, requested = "auto", shared) {
144
146
  if (!content.trim())
145
147
  throw new ScanInputError("Content must not be empty or whitespace.");
148
+ if (requested === "package" || (requested === "auto" && looksLikePackage(content)))
149
+ return parsePackage(content);
146
150
  // Detect these before HCL or a failing YAML parse so a Dockerfile is not classified as Terraform.
147
151
  if (requested === "dockerfile" ||
148
152
  ((requested === "auto" || requested === "terraform") &&
@@ -412,12 +416,28 @@ export function parseIac(content, requested = "auto") {
412
416
  Array.isArray(root.Resources) ||
413
417
  typeof root.Resources !== "object")
414
418
  throw new ScanInputError("CloudFormation requires a Resources mapping.");
415
- const parameters = cloudFormationParameterLiterals(root);
419
+ const localParameters = cloudFormationParameterLiterals(root);
420
+ const localMappings = cloudFormationMappings(root);
421
+ const parameters = {
422
+ defaults: new Map(shared?.defaults ?? localParameters.defaults),
423
+ secretNames: new Set(shared?.secretNames ?? localParameters.secretNames),
424
+ };
425
+ const mappings = new Map(shared?.mappings ?? localMappings);
426
+ if (shared) {
427
+ for (const [name, value] of localParameters.defaults)
428
+ if (!parameters.defaults.has(name))
429
+ parameters.defaults.set(name, value);
430
+ for (const name of localParameters.secretNames)
431
+ parameters.secretNames.add(name);
432
+ for (const [name, value] of localMappings)
433
+ if (!mappings.has(name))
434
+ mappings.set(name, value);
435
+ }
416
436
  for (const [name, raw] of Object.entries(object(root.Resources))) {
417
437
  const value = object(raw);
418
438
  if (typeof value.Type !== "string")
419
439
  throw new ScanInputError("Each CloudFormation resource requires a Type.");
420
- add(name, value.Type, resolveCloudFormationRefs(value, parameters), ["Resources", name]);
440
+ add(name, value.Type, resolveCloudFormationRefs(value, parameters, mappings), ["Resources", name]);
421
441
  }
422
442
  }
423
443
  else if (format === "terraform") {
@@ -465,12 +485,12 @@ export function parseIac(content, requested = "auto") {
465
485
  }
466
486
  }
467
487
  if (!format)
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.");
488
+ 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, a standalone IAM policy, or a package.json.");
469
489
  if (!resources.length)
470
490
  warnings.push("No resources found in the submitted document.");
471
491
  if (format === "cloudformation" &&
472
492
  /(?:!(?: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.");
493
+ warnings.push("Same-file and cross-file parameter defaults are resolved. A simple Fn::Sub, Fn::Join, and Fn::FindInMap of known literals are resolved. Other functions are not evaluated.");
474
494
  else if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
475
495
  warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
476
496
  return { format, resources, warnings };
@@ -1413,6 +1433,58 @@ function parameterNameIsSecret(name) {
1413
1433
  /(?:^|_)(?:api_?key|auth_token|access_key|private_key)$/i.test(normalized) ||
1414
1434
  /(?:password|passwd|secret|api[_-]?key|auth[_-]?token|access[_-]?key|private[_-]?key|token)$/i.test(name));
1415
1435
  }
1436
+ function cloudFormationMappings(root) {
1437
+ const mappings = new Map();
1438
+ for (const [mapName, top] of Object.entries(object(root.Mappings))) {
1439
+ const levels = {};
1440
+ for (const [topKey, second] of Object.entries(object(top))) {
1441
+ const entries = {};
1442
+ for (const [secondKey, raw] of Object.entries(object(second))) {
1443
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean")
1444
+ entries[secondKey] = raw;
1445
+ }
1446
+ levels[topKey] = entries;
1447
+ }
1448
+ mappings.set(mapName, levels);
1449
+ }
1450
+ return mappings;
1451
+ }
1452
+ export function readCloudFormationTables(content) {
1453
+ const empty = () => ({
1454
+ defaults: new Map(),
1455
+ secretNames: new Set(),
1456
+ mappings: new Map(),
1457
+ });
1458
+ const trimmed = content.trim();
1459
+ if (!trimmed)
1460
+ return empty();
1461
+ let root;
1462
+ try {
1463
+ if (trimmed.startsWith("{"))
1464
+ root = object(JSON.parse(trimmed));
1465
+ else {
1466
+ const docs = parseAllDocuments(trimmed, { prettyErrors: false });
1467
+ const doc = docs[0];
1468
+ if (!doc || doc.errors.length)
1469
+ return empty();
1470
+ root = object(doc.toJS({ maxAliasCount: 0 }));
1471
+ }
1472
+ }
1473
+ catch {
1474
+ return empty();
1475
+ }
1476
+ if (!root.Parameters &&
1477
+ !root.Mappings &&
1478
+ !root.Resources &&
1479
+ typeof root.AWSTemplateFormatVersion !== "string")
1480
+ return empty();
1481
+ const parameters = cloudFormationParameterLiterals(root);
1482
+ return {
1483
+ defaults: parameters.defaults,
1484
+ secretNames: parameters.secretNames,
1485
+ mappings: cloudFormationMappings(root),
1486
+ };
1487
+ }
1416
1488
  function cloudFormationParameterLiterals(root) {
1417
1489
  const defaults = new Map();
1418
1490
  const secretNames = new Set();
@@ -1445,22 +1517,93 @@ function cloudFormationIntrinsic(value) {
1445
1517
  return false;
1446
1518
  return Object.keys(value).some((key) => key === "Fn::Sub" || key === "Fn::Join" || key === "Fn::FindInMap" || key.startsWith("Fn::"));
1447
1519
  }
1448
- function resolveCloudFormationRefs(value, parameters) {
1520
+ function cloudFormationSubTemplate(value) {
1521
+ if (!value || typeof value !== "object" || Array.isArray(value))
1522
+ return undefined;
1523
+ const entries = Object.entries(value).filter(([key]) => key !== "__referencedSecrets");
1524
+ if (entries.length !== 1)
1525
+ return undefined;
1526
+ const [key, template] = entries[0];
1527
+ if (key !== "Fn::Sub" || typeof template !== "string")
1528
+ return undefined;
1529
+ return template;
1530
+ }
1531
+ function substituteKnownParameters(template, parameters) {
1532
+ let unresolved = false;
1533
+ let secret = false;
1534
+ const names = [];
1535
+ const text = template.replace(/\$\{(!?)([^}]*)\}/g, (full, literalMark, rawName) => {
1536
+ if (literalMark === "!")
1537
+ return `\${${rawName}}`;
1538
+ const name = rawName.trim();
1539
+ if (!parameters.defaults.has(name)) {
1540
+ unresolved = true;
1541
+ return full;
1542
+ }
1543
+ names.push(name);
1544
+ if (parameters.secretNames.has(name))
1545
+ secret = true;
1546
+ return String(parameters.defaults.get(name));
1547
+ });
1548
+ if (unresolved)
1549
+ return undefined;
1550
+ return { text, secret, names };
1551
+ }
1552
+ function cloudFormationJoin(value) {
1553
+ if (!value || typeof value !== "object" || Array.isArray(value))
1554
+ return undefined;
1555
+ const entries = Object.entries(value).filter(([key]) => key !== "__referencedSecrets" && key !== "__valueOrigins");
1556
+ if (entries.length !== 1)
1557
+ return undefined;
1558
+ const [key, body] = entries[0];
1559
+ if (key !== "Fn::Join" || !Array.isArray(body) || body.length !== 2 || !Array.isArray(body[1]))
1560
+ return undefined;
1561
+ return [body[0], body[1]];
1562
+ }
1563
+ function cloudFormationFindInMap(value) {
1564
+ if (!value || typeof value !== "object" || Array.isArray(value))
1565
+ return undefined;
1566
+ const entries = Object.entries(value).filter(([key]) => key !== "__referencedSecrets" && key !== "__valueOrigins");
1567
+ if (entries.length !== 1)
1568
+ return undefined;
1569
+ const [key, body] = entries[0];
1570
+ if (key !== "Fn::FindInMap" || !Array.isArray(body) || body.length !== 3)
1571
+ return undefined;
1572
+ return [body[0], body[1], body[2]];
1573
+ }
1574
+ function knownLiteral(value) {
1575
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
1576
+ return String(value);
1577
+ return undefined;
1578
+ }
1579
+ function recordSingleOrigin(origins, path, names) {
1580
+ const unique = [...new Set(names)];
1581
+ if (unique.length === 1)
1582
+ origins.set(path.join("."), unique[0]);
1583
+ }
1584
+ function resolveCloudFormationRefs(value, parameters, mappings) {
1449
1585
  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
- });
1586
+ const origins = new Map();
1587
+ const resolved = resolveCloudFormationValue(value, parameters, mappings, [], secretPaths, origins);
1588
+ if (resolved && typeof resolved === "object" && !Array.isArray(resolved)) {
1589
+ if (secretPaths.length > 0) {
1590
+ Object.defineProperty(resolved, "__referencedSecrets", {
1591
+ enumerable: false,
1592
+ configurable: true,
1593
+ value: secretPaths,
1594
+ });
1595
+ }
1596
+ if (origins.size > 0) {
1597
+ Object.defineProperty(resolved, "__valueOrigins", {
1598
+ enumerable: false,
1599
+ configurable: true,
1600
+ value: origins,
1601
+ });
1602
+ }
1460
1603
  }
1461
1604
  return object(resolved);
1462
1605
  }
1463
- function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1606
+ function resolveCloudFormationValue(value, parameters, mappings, path, secretPaths, origins) {
1464
1607
  const ref = cloudFormationRefName(value);
1465
1608
  if (ref) {
1466
1609
  if (!parameters.defaults.has(ref))
@@ -1470,23 +1613,75 @@ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1470
1613
  typeof literal === "string" &&
1471
1614
  literal.trim() !== "")
1472
1615
  secretPaths.push(path);
1616
+ origins.set(path.join("."), `parameter ${ref}`);
1473
1617
  return literal;
1474
1618
  }
1619
+ const subTemplate = cloudFormationSubTemplate(value);
1620
+ if (subTemplate !== undefined) {
1621
+ const substituted = substituteKnownParameters(subTemplate, parameters);
1622
+ if (!substituted)
1623
+ return value;
1624
+ if (substituted.secret)
1625
+ secretPaths.push(path);
1626
+ recordSingleOrigin(origins, path, substituted.names.map((name) => `parameter ${name}`));
1627
+ return substituted.text;
1628
+ }
1629
+ const join = cloudFormationJoin(value);
1630
+ if (join) {
1631
+ const [separator, list] = join;
1632
+ const resolvedSeparator = resolveCloudFormationValue(separator, parameters, mappings, [...path, 0], secretPaths, origins);
1633
+ const separatorText = knownLiteral(resolvedSeparator);
1634
+ if (separatorText === undefined)
1635
+ return value;
1636
+ const parts = [];
1637
+ for (const [index, item] of list.entries()) {
1638
+ const resolved = resolveCloudFormationValue(item, parameters, mappings, [...path, 1, index], secretPaths, origins);
1639
+ const text = knownLiteral(resolved);
1640
+ if (text === undefined)
1641
+ return value;
1642
+ parts.push(text);
1643
+ }
1644
+ return parts.join(separatorText);
1645
+ }
1646
+ const find = cloudFormationFindInMap(value);
1647
+ if (find) {
1648
+ const [mapName, topKey, secondKey] = find.map((key) => knownLiteral(key));
1649
+ if (!mapName || !topKey || !secondKey)
1650
+ return value;
1651
+ const mapped = mappings.get(mapName)?.[topKey]?.[secondKey];
1652
+ if (mapped === undefined)
1653
+ return value;
1654
+ return mapped;
1655
+ }
1475
1656
  if (cloudFormationIntrinsic(value))
1476
1657
  return value;
1477
1658
  if (Array.isArray(value))
1478
- return value.map((item, index) => resolveCloudFormationValue(item, parameters, [...path, index], secretPaths));
1659
+ return value.map((item, index) => resolveCloudFormationValue(item, parameters, mappings, [...path, index], secretPaths, origins));
1479
1660
  if (value && typeof value === "object") {
1480
1661
  const out = {};
1481
1662
  for (const [key, child] of Object.entries(value)) {
1482
- if (key === "__referencedSecrets")
1663
+ if (key === "__referencedSecrets" || key === "__valueOrigins")
1483
1664
  continue;
1484
- out[key] = resolveCloudFormationValue(child, parameters, [...path, key], secretPaths);
1665
+ out[key] = resolveCloudFormationValue(child, parameters, mappings, [...path, key], secretPaths, origins);
1485
1666
  }
1486
1667
  return out;
1487
1668
  }
1488
1669
  return value;
1489
1670
  }
1671
+ export function terraformJsonencodeValue(expression) {
1672
+ const match = /^\$\{jsonencode\(([\s\S]*)\)\}$/.exec(expression.trim());
1673
+ if (!match)
1674
+ return undefined;
1675
+ try {
1676
+ const [parsed, error] = hcl.parseToObject(`value = ${match[1]}\n`);
1677
+ if (error || !parsed)
1678
+ return undefined;
1679
+ return object(parsed).value;
1680
+ }
1681
+ catch {
1682
+ return undefined;
1683
+ }
1684
+ }
1490
1685
  function terraformLocalBlocks(data) {
1491
1686
  const raw = object(data).locals;
1492
1687
  if (Array.isArray(raw))
@@ -1505,6 +1700,86 @@ export function looksLikeServerless(content) {
1505
1700
  return false;
1506
1701
  return topLevelKey(content, "functions") || topLevelKey(content, "plugins");
1507
1702
  }
1703
+ function plainObject(value) {
1704
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1705
+ }
1706
+ export function looksLikePackage(content) {
1707
+ const trimmed = content.trim();
1708
+ if (!trimmed.startsWith("{"))
1709
+ return false;
1710
+ let value;
1711
+ try {
1712
+ value = JSON.parse(trimmed);
1713
+ }
1714
+ catch {
1715
+ return false;
1716
+ }
1717
+ const root = object(value);
1718
+ if (typeof root.name !== "string" || root.name.trim() === "")
1719
+ return false;
1720
+ if ("resource" in root || "Resources" in root || typeof root.AWSTemplateFormatVersion === "string")
1721
+ return false;
1722
+ if (looksLikeIamPolicy(trimmed))
1723
+ return false;
1724
+ return (plainObject(root.dependencies) ||
1725
+ plainObject(root.devDependencies) ||
1726
+ plainObject(root.scripts));
1727
+ }
1728
+ function scriptPipesDownload(command) {
1729
+ return /\b(?:curl|wget)\b[\s\S]*\|\s*(?:ba)?sh\b/.test(command);
1730
+ }
1731
+ function jsonKeyLine(content, key) {
1732
+ const match = new RegExp(`"${escapeRegex(key)}"\\s*:`).exec(content);
1733
+ if (!match)
1734
+ return 1;
1735
+ return content.slice(0, match.index).split("\n").length;
1736
+ }
1737
+ function parsePackage(content) {
1738
+ if (!looksLikePackage(content))
1739
+ throw new ScanInputError("A package.json requires a top-level name plus dependencies, devDependencies, or scripts.");
1740
+ const root = object(JSON.parse(content));
1741
+ const resources = [];
1742
+ resources.push({
1743
+ id: String(root.name),
1744
+ type: "package:manifest",
1745
+ value: { name: root.name },
1746
+ locate: () => ({ line: jsonKeyLine(content, "name"), column: 1, path: "name" }),
1747
+ });
1748
+ const scripts = object(root.scripts);
1749
+ for (const name of ["preinstall", "install", "postinstall"]) {
1750
+ if (typeof scripts[name] !== "string")
1751
+ continue;
1752
+ resources.push({
1753
+ id: `scripts.${name}`,
1754
+ type: "package:script",
1755
+ value: { name, command: scripts[name], pipes: scriptPipesDownload(scripts[name]) },
1756
+ locate: () => ({
1757
+ line: jsonKeyLine(content, name),
1758
+ column: 1,
1759
+ path: `scripts.${name}`,
1760
+ }),
1761
+ });
1762
+ }
1763
+ for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) {
1764
+ if (!plainObject(root[field]))
1765
+ continue;
1766
+ for (const [dep, range] of Object.entries(root[field])) {
1767
+ if (range !== "*" && range !== "latest")
1768
+ continue;
1769
+ resources.push({
1770
+ id: `${field}.${dep}`,
1771
+ type: "package:dependency",
1772
+ value: { field, name: dep, range },
1773
+ locate: () => ({
1774
+ line: jsonKeyLine(content, dep),
1775
+ column: 1,
1776
+ path: `${field}.${dep}`,
1777
+ }),
1778
+ });
1779
+ }
1780
+ }
1781
+ return { format: "package", resources, warnings: [] };
1782
+ }
1508
1783
  export function looksLikeIamPolicy(content) {
1509
1784
  const trimmed = content.trim();
1510
1785
  if (!trimmed.startsWith("{"))
@@ -9,7 +9,13 @@ type Rule = {
9
9
  remediation: string;
10
10
  };
11
11
  export declare const rules: Record<string, Rule>;
12
- export declare function resourceFindings(resources: Resource[], format: string): {
12
+ export type TerraformLiterals = {
13
+ vars: Map<string, string | boolean | number>;
14
+ locals: Map<string, string | boolean | number>;
15
+ declaredSecrets: Set<string>;
16
+ };
17
+ export declare function terraformLiterals(resources: Resource[]): TerraformLiterals;
18
+ export declare function resourceFindings(resources: Resource[], format: string, shared?: TerraformLiterals): {
13
19
  findings: Finding[];
14
20
  checked: Set<string>;
15
21
  };
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",
@@ -365,13 +372,31 @@ export const rules = {
365
372
  detail: "A Serverless Framework environment entry assigns a non-empty literal to a secret-like name.",
366
373
  remediation: "Reference the value with a Serverless variable such as ${env:TOKEN}. Do not commit a literal secret.",
367
374
  },
375
+ PKG001: {
376
+ category: "security",
377
+ severity: "high",
378
+ title: "Package script pipes a download to a shell",
379
+ detail: "A package install script downloads with curl or wget and pipes the result to a shell.",
380
+ remediation: "Run a reviewed local script instead of piping a remote download to a shell.",
381
+ },
382
+ PKG002: {
383
+ category: "security",
384
+ severity: "medium",
385
+ title: "Dependency range is floating",
386
+ detail: "A dependency range is * or latest.",
387
+ remediation: "Pin the dependency to a reviewed version.",
388
+ },
368
389
  };
369
- export function resourceFindings(resources, format) {
390
+ export function terraformLiterals(resources) {
391
+ return collectSameFileLiterals(resources);
392
+ }
393
+ export function resourceFindings(resources, format, shared) {
370
394
  const findings = [];
371
395
  const checked = new Set();
372
- const sameFile = collectSameFileLiterals(resources);
396
+ const sameFile = shared ?? collectSameFileLiterals(resources);
373
397
  const suppressedSecrets = new WeakMap();
374
398
  const moduleSecrets = new WeakMap();
399
+ const valueOrigins = new WeakMap();
375
400
  if (format === "terraform") {
376
401
  for (const resource of resources) {
377
402
  if (resource.type === "terraform:variable" ||
@@ -380,18 +405,22 @@ export function resourceFindings(resources, format) {
380
405
  continue;
381
406
  const suppressed = new Set();
382
407
  const referenced = new Set();
383
- resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced);
408
+ const origins = new Map();
409
+ resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced, origins);
384
410
  suppressedSecrets.set(resource, suppressed);
385
411
  moduleSecrets.set(resource, referenced);
412
+ valueOrigins.set(resource, origins);
386
413
  }
387
414
  }
388
415
  for (const resource of resources) {
416
+ const origins = valueOrigins.get(resource) ?? cloudFormationOrigins(resource);
389
417
  const check = (id, condition, path = []) => {
390
418
  checked.add(id);
391
419
  if (condition)
392
420
  findings.push({
393
421
  ruleId: id,
394
422
  ...rules[id],
423
+ detail: `${rules[id].detail}${originSentence(origins, path)}`,
395
424
  resource: resource.id,
396
425
  location: resource.locate(path),
397
426
  });
@@ -431,6 +460,10 @@ export function resourceFindings(resources, format) {
431
460
  check("NET001", resource.value.openSourceIp === true, ["Condition"]);
432
461
  continue;
433
462
  }
463
+ if (format === "package") {
464
+ checkPackage(resource, check);
465
+ continue;
466
+ }
434
467
  if (resource.type === "terraform:local") {
435
468
  checkTerraformLiterals(resource.value, "local", check, []);
436
469
  continue;
@@ -573,6 +606,211 @@ function checkTerraformLiterals(value, mode, check, path, name = "", referencedS
573
606
  }
574
607
  }
575
608
  const exactSameFileRef = /^\$\{(var|local)\.([A-Za-z_][A-Za-z0-9_]*)\}$/;
609
+ const interpolationPiece = /\$\{(var|local)\.([A-Za-z_][A-Za-z0-9_]*)\}/g;
610
+ const interpolationOnly = /^(\$\{(?:var|local)\.[A-Za-z_][A-Za-z0-9_]*\})+$/;
611
+ function sameFileText(kind, name, sameFile) {
612
+ const literal = (kind === "var" ? sameFile.vars : sameFile.locals).get(name);
613
+ if (typeof literal === "string" || typeof literal === "number" || typeof literal === "boolean")
614
+ return String(literal);
615
+ return undefined;
616
+ }
617
+ function resolveInterpolationString(value, sameFile) {
618
+ const trimmed = value.trim();
619
+ if (!interpolationOnly.test(trimmed))
620
+ return undefined;
621
+ const sources = [];
622
+ let count = 0;
623
+ let failed = false;
624
+ const text = trimmed.replace(interpolationPiece, (_full, kind, name) => {
625
+ count += 1;
626
+ const part = sameFileText(kind, name, sameFile);
627
+ if (part === undefined) {
628
+ failed = true;
629
+ return "";
630
+ }
631
+ sources.push(`${kind}.${name}`);
632
+ return part;
633
+ });
634
+ if (failed || count < 2)
635
+ return undefined;
636
+ return { text, sources };
637
+ }
638
+ function splitCallArguments(source) {
639
+ const args = [];
640
+ let current = "";
641
+ let quote = null;
642
+ let depth = 0;
643
+ for (let index = 0; index < source.length; index += 1) {
644
+ const char = source[index];
645
+ if (quote) {
646
+ current += char;
647
+ if (char === "\\" && index + 1 < source.length) {
648
+ current += source[index + 1];
649
+ index += 1;
650
+ continue;
651
+ }
652
+ if (char === quote)
653
+ quote = null;
654
+ continue;
655
+ }
656
+ if (char === '"' || char === "'") {
657
+ quote = char;
658
+ current += char;
659
+ continue;
660
+ }
661
+ if (char === "(" || char === "[" || char === "{") {
662
+ depth += 1;
663
+ current += char;
664
+ continue;
665
+ }
666
+ if (char === ")" || char === "]" || char === "}") {
667
+ if (depth === 0)
668
+ return undefined;
669
+ depth -= 1;
670
+ current += char;
671
+ continue;
672
+ }
673
+ if (char === "," && depth === 0) {
674
+ args.push(current.trim());
675
+ current = "";
676
+ continue;
677
+ }
678
+ current += char;
679
+ }
680
+ if (quote || depth !== 0)
681
+ return undefined;
682
+ if (current.trim() !== "")
683
+ args.push(current.trim());
684
+ return args;
685
+ }
686
+ function unquoteHcl(token) {
687
+ if (token.length < 2)
688
+ return undefined;
689
+ const quote = token[0];
690
+ if ((quote !== '"' && quote !== "'") || token[token.length - 1] !== quote)
691
+ return undefined;
692
+ const body = token.slice(1, -1);
693
+ return quote === '"'
694
+ ? body.replace(/\\n/g, "\n").replace(/\\"/g, '"').replace(/\\\\/g, "\\")
695
+ : body.replace(/\\'/g, "'").replace(/\\\\/g, "\\");
696
+ }
697
+ function resolveFormatArgument(token, sameFile) {
698
+ const literal = unquoteHcl(token);
699
+ if (literal !== undefined) {
700
+ const ref = exactSameFileRef.exec(literal.trim());
701
+ if (!ref) {
702
+ if (literal.includes("${"))
703
+ return undefined;
704
+ return { text: literal };
705
+ }
706
+ const text = sameFileText(ref[1], ref[2], sameFile);
707
+ if (text === undefined)
708
+ return undefined;
709
+ return { text, source: `${ref[1]}.${ref[2]}` };
710
+ }
711
+ if (/^-?\d+(?:\.\d+)?$/.test(token) || token === "true" || token === "false")
712
+ return { text: token };
713
+ const ref = /^(var|local)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(token);
714
+ if (!ref)
715
+ return undefined;
716
+ const text = sameFileText(ref[1], ref[2], sameFile);
717
+ if (text === undefined)
718
+ return undefined;
719
+ return { text, source: `${ref[1]}.${ref[2]}` };
720
+ }
721
+ function applyPercentS(format, args) {
722
+ let index = 0;
723
+ let text = "";
724
+ for (let cursor = 0; cursor < format.length; cursor += 1) {
725
+ if (format[cursor] !== "%") {
726
+ text += format[cursor];
727
+ continue;
728
+ }
729
+ const verb = format[cursor + 1];
730
+ if (verb === "%") {
731
+ text += "%";
732
+ cursor += 1;
733
+ continue;
734
+ }
735
+ if (verb !== "s" || index >= args.length)
736
+ return undefined;
737
+ text += args[index];
738
+ index += 1;
739
+ cursor += 1;
740
+ }
741
+ return text;
742
+ }
743
+ function resolveFormatCall(value, sameFile) {
744
+ const wrapped = /^\$\{format\(([\s\S]*)\)\}$/.exec(value.trim());
745
+ if (!wrapped)
746
+ return undefined;
747
+ const args = splitCallArguments(wrapped[1]);
748
+ if (!args || args.length === 0)
749
+ return undefined;
750
+ const resolved = [];
751
+ const sources = [];
752
+ for (const arg of args) {
753
+ const part = resolveFormatArgument(arg, sameFile);
754
+ if (part === undefined)
755
+ return undefined;
756
+ resolved.push(part.text);
757
+ if (part.source)
758
+ sources.push(part.source);
759
+ }
760
+ const text = applyPercentS(resolved[0], resolved.slice(1));
761
+ if (text === undefined)
762
+ return undefined;
763
+ return { text, sources };
764
+ }
765
+ function parseHclList(token) {
766
+ const trimmed = token.trim();
767
+ if (!trimmed.startsWith("[") || !trimmed.endsWith("]"))
768
+ return undefined;
769
+ return splitCallArguments(trimmed.slice(1, -1)) ?? [];
770
+ }
771
+ function resolveJoinCall(value, sameFile, depth = 0) {
772
+ if (depth > 8)
773
+ return undefined;
774
+ const wrapped = /^\$\{join\(([\s\S]*)\)\}$/.exec(value.trim());
775
+ const bare = /^join\(([\s\S]*)\)$/.exec(value.trim());
776
+ const source = wrapped?.[1] ?? bare?.[1];
777
+ if (source === undefined)
778
+ return undefined;
779
+ const args = splitCallArguments(source);
780
+ if (!args || args.length !== 2)
781
+ return undefined;
782
+ const separator = resolveJoinPiece(args[0], sameFile, depth);
783
+ const list = parseHclList(args[1]);
784
+ if (!separator || !list)
785
+ return undefined;
786
+ const parts = [];
787
+ const sources = [...separator.sources];
788
+ for (const element of list) {
789
+ const resolved = resolveJoinPiece(element, sameFile, depth);
790
+ if (!resolved)
791
+ return undefined;
792
+ parts.push(resolved.text);
793
+ sources.push(...resolved.sources);
794
+ }
795
+ return { text: parts.join(separator.text), sources };
796
+ }
797
+ function resolveJoinPiece(token, sameFile, depth) {
798
+ if (/^join\(/.test(token.trim()))
799
+ return resolveJoinCall(`\${${token.trim()}}`, sameFile, depth + 1);
800
+ const resolved = resolveFormatArgument(token, sameFile);
801
+ if (!resolved)
802
+ return undefined;
803
+ return { text: resolved.text, sources: resolved.source ? [resolved.source] : [] };
804
+ }
805
+ function noteResolvedLiteral(literal, path, sameFile, suppressed, referencedSecrets) {
806
+ if (literal.trim() === "")
807
+ return;
808
+ const key = [...path].reverse().find((part) => typeof part === "string");
809
+ if (sameFile.declaredSecrets.has(literal))
810
+ suppressed.add(path.join("."));
811
+ else if (typeof key === "string" && secretAssignmentName(key))
812
+ referencedSecrets.add(path.join("."));
813
+ }
576
814
  function sameFileLiteral(value) {
577
815
  if (typeof value === "boolean" || typeof value === "number")
578
816
  return value;
@@ -607,38 +845,81 @@ function collectSameFileLiterals(resources) {
607
845
  }
608
846
  return { vars, locals, declaredSecrets };
609
847
  }
610
- function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecrets) {
848
+ function recordResolvedSources(origins, path, sources) {
849
+ const unique = [...new Set(sources)];
850
+ if (unique.length === 1)
851
+ origins.set(path.join("."), unique[0]);
852
+ }
853
+ function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecrets, origins) {
611
854
  if (typeof value === "string") {
612
855
  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;
856
+ if (match) {
857
+ const literal = (match[1] === "var" ? sameFile.vars : sameFile.locals).get(match[2]);
858
+ if (literal === undefined)
859
+ return value;
860
+ if (typeof literal === "string")
861
+ noteResolvedLiteral(literal, path, sameFile, suppressed, referencedSecrets);
862
+ origins.set(path.join("."), `${match[1]}.${match[2]}`);
863
+ return literal;
864
+ }
865
+ const concatenated = resolveInterpolationString(value, sameFile);
866
+ if (concatenated !== undefined) {
867
+ noteResolvedLiteral(concatenated.text, path, sameFile, suppressed, referencedSecrets);
868
+ recordResolvedSources(origins, path, concatenated.sources);
869
+ return concatenated.text;
870
+ }
871
+ const formatted = resolveFormatCall(value, sameFile);
872
+ if (formatted !== undefined) {
873
+ noteResolvedLiteral(formatted.text, path, sameFile, suppressed, referencedSecrets);
874
+ recordResolvedSources(origins, path, formatted.sources);
875
+ return formatted.text;
876
+ }
877
+ const joined = resolveJoinCall(value, sameFile);
878
+ if (joined !== undefined) {
879
+ noteResolvedLiteral(joined.text, path, sameFile, suppressed, referencedSecrets);
880
+ recordResolvedSources(origins, path, joined.sources);
881
+ return joined.text;
882
+ }
883
+ return value;
626
884
  }
627
885
  if (Array.isArray(value)) {
628
886
  value.forEach((item, index) => {
629
- value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets);
887
+ value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets, origins);
630
888
  });
631
889
  return value;
632
890
  }
633
891
  if (value && typeof value === "object") {
634
892
  for (const [key, child] of Object.entries(value)) {
635
- if (key === "__referencedSecrets")
893
+ if (key === "__referencedSecrets" || key === "__valueOrigins")
636
894
  continue;
637
- value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets);
895
+ value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets, origins);
638
896
  }
639
897
  }
640
898
  return value;
641
899
  }
900
+ function cloudFormationOrigins(resource) {
901
+ const stored = Object.getOwnPropertyDescriptor(resource.value, "__valueOrigins")?.value;
902
+ return stored instanceof Map ? stored : new Map();
903
+ }
904
+ function originSentence(origins, path) {
905
+ if (origins.size === 0)
906
+ return "";
907
+ const prefix = path.join(".");
908
+ const sources = new Set();
909
+ for (const [key, source] of origins) {
910
+ if (key === prefix || key.startsWith(`${prefix}.`))
911
+ sources.add(source);
912
+ }
913
+ if (sources.size !== 1)
914
+ return "";
915
+ return ` Value comes from ${[...sources][0]}.`;
916
+ }
917
+ function checkPackage(resource, check) {
918
+ if (resource.type === "package:script")
919
+ check("PKG001", resource.value.pipes === true, ["command"]);
920
+ else if (resource.type === "package:dependency")
921
+ check("PKG002", resource.value.range === "*" || resource.value.range === "latest", ["range"]);
922
+ }
642
923
  function dedupeSecretFindings(findings) {
643
924
  const seen = new Set();
644
925
  return findings.filter((finding) => {
@@ -705,6 +986,39 @@ function checkCompose(resource, check) {
705
986
  service.mem_reservation == null, ["mem_limit"]);
706
987
  check("NET001", array(service.ports).some(composePortIsPublic), ["ports"]);
707
988
  }
989
+ function statementEffectAllows(body) {
990
+ const effect = body.Effect ?? body.effect;
991
+ if (effect == null || String(effect).trim() === "")
992
+ return true;
993
+ return String(effect).trim().toLowerCase() === "allow";
994
+ }
995
+ function principalAllowsAnyone(value) {
996
+ if (typeof value === "string")
997
+ return value.trim() === "*";
998
+ if (Array.isArray(value))
999
+ return value.some((item) => principalAllowsAnyone(item));
1000
+ if (value && typeof value === "object")
1001
+ return Object.values(value).some((item) => principalAllowsAnyone(item));
1002
+ return false;
1003
+ }
1004
+ function actionIncludesWildcard(value) {
1005
+ return array(value).some((item) => typeof item === "string" && item.includes("*"));
1006
+ }
1007
+ function checkInlinePolicy(document, check, path) {
1008
+ const root = object(document);
1009
+ const statements = root.Statement ?? root.statement;
1010
+ if (!Array.isArray(statements) && (statements == null || typeof statements !== "object"))
1011
+ return;
1012
+ for (const statement of array(statements)) {
1013
+ const body = object(statement);
1014
+ if (!statementEffectAllows(body))
1015
+ continue;
1016
+ check("IAM001", actionIncludesWildcard(body.Action ?? body.action ?? body.Actions ?? body.actions), path);
1017
+ const principal = body.Principal ?? body.principal ?? body.Principals ?? body.principals;
1018
+ if (principal !== undefined)
1019
+ check("IAM002", principalAllowsAnyone(principal), path);
1020
+ }
1021
+ }
708
1022
  function checkCloud(resource, resources, format, check) {
709
1023
  const tf = format === "terraform";
710
1024
  const pulumi = format === "pulumi";
@@ -727,31 +1041,30 @@ function checkCloud(resource, resources, format, check) {
727
1041
  isIngress) {
728
1042
  check("NET001", array(child).some((cidr) => cidr === "0.0.0.0/0" || cidr === "::/0"), [...prefix, ...at]);
729
1043
  }
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]);
1044
+ if (/^(?:Action|actions)$/.test(key) && statementEffectAllows(parent)) {
1045
+ check("IAM001", actionIncludesWildcard(child), [...prefix, ...at]);
1046
+ }
1047
+ if (/^(?:Principal|principals)$/.test(key) && statementEffectAllows(parent)) {
1048
+ check("IAM002", principalAllowsAnyone(child), [...prefix, ...at]);
1049
+ }
1050
+ if (typeof child === "string") {
1051
+ const encoded = child.trim().startsWith("${jsonencode(")
1052
+ ? terraformJsonencodeValue(child)
1053
+ : undefined;
1054
+ let parsed;
1055
+ if (encoded === undefined &&
1056
+ /^(?:policy|assume_role_policy|PolicyDocument)$/.test(key) &&
1057
+ child.trimStart().startsWith("{")) {
1058
+ try {
1059
+ parsed = JSON.parse(child);
1060
+ }
1061
+ catch {
1062
+ parsed = undefined;
750
1063
  }
751
1064
  }
752
- catch {
753
- /* Nonliteral policies are covered by the expression warning. */
754
- }
1065
+ const document = encoded ?? parsed;
1066
+ if (document !== undefined)
1067
+ checkInlinePolicy(document, check, [...prefix, ...at]);
755
1068
  }
756
1069
  if (/^(?:instance_type|machine_type|vm_size|InstanceType|DBInstanceClass|instance_class)$/.test(key))
757
1070
  check("COST001", typeof child === "string", [...prefix, ...at]);
@@ -16,7 +16,7 @@ export declare function summarize(findings: Finding[]): {
16
16
  };
17
17
  export declare function filterFindings(findings: Finding[], options: ScanOptions): Finding[];
18
18
  export declare function scanIac(content: string, options?: ScanOptions): {
19
- status: string;
19
+ status: "completed";
20
20
  engineVersion: string;
21
21
  format: IacFormat;
22
22
  focus: "security" | "reliability" | "cost" | "general";
package/dist/iac/scan.js CHANGED
@@ -1,5 +1,5 @@
1
- import { parseIac } from "./parse.js";
2
- import { resourceFindings } from "./rules.js";
1
+ import { parseIac, readCloudFormationTables, ScanInputError } from "./parse.js";
2
+ import { resourceFindings, terraformLiterals } from "./rules.js";
3
3
  import { VERSION } from "../version.js";
4
4
  export const severityRank = {
5
5
  info: 0,
@@ -8,6 +8,7 @@ export const severityRank = {
8
8
  high: 3,
9
9
  critical: 4,
10
10
  };
11
+ const markerLine = /^----- skaleagents-file: (.+) -----$/;
11
12
  export function summarize(findings) {
12
13
  const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
13
14
  for (const finding of findings)
@@ -24,29 +25,89 @@ export function filterFindings(findings, options) {
24
25
  (a.location?.line ?? 0) - (b.location?.line ?? 0) ||
25
26
  (a.ruleId ?? "").localeCompare(b.ruleId ?? ""));
26
27
  }
27
- export function scanIac(content, options = {}) {
28
- const parsed = parseIac(content, options.format);
29
- const result = resourceFindings(parsed.resources, parsed.format);
30
- const findings = filterFindings(result.findings, options);
28
+ function fileBasename(name) {
29
+ const trimmed = name.trim();
30
+ const base = trimmed.split(/[/\\]/).filter((part) => part.length > 0).pop() ?? "";
31
+ return base.trim();
32
+ }
33
+ function splitSubmittedFiles(content) {
34
+ const lines = content.split("\n");
35
+ const markers = [];
36
+ for (let index = 0; index < lines.length; index += 1) {
37
+ const line = lines[index].endsWith("\r") ? lines[index].slice(0, -1) : lines[index];
38
+ const match = markerLine.exec(line);
39
+ if (!match)
40
+ continue;
41
+ const name = fileBasename(match[1]);
42
+ if (!name || name.includes("----- skaleagents-file:"))
43
+ continue;
44
+ markers.push({ index, name });
45
+ }
46
+ if (markers.length === 0)
47
+ return undefined;
48
+ const parts = [];
49
+ const preamble = lines.slice(0, markers[0].index).join("\n");
50
+ if (preamble.trim())
51
+ parts.push({ name: "", text: preamble });
52
+ for (let index = 0; index < markers.length; index += 1) {
53
+ const start = markers[index].index + 1;
54
+ const end = index + 1 < markers.length ? markers[index + 1].index : lines.length;
55
+ parts.push({ name: markers[index].name, text: lines.slice(start, end).join("\n") });
56
+ }
57
+ return parts;
58
+ }
59
+ function labelResource(resource, fileName) {
60
+ if (!fileName)
61
+ return resource;
62
+ return {
63
+ id: `${fileName}:${resource.id}`,
64
+ type: resource.type,
65
+ value: resource.value,
66
+ locate: (path) => {
67
+ const location = resource.locate(path);
68
+ return { ...location, path: `${fileName}:${location.path}` };
69
+ },
70
+ };
71
+ }
72
+ function mergeTables(parts) {
73
+ const defaults = new Map();
74
+ const secretNames = new Set();
75
+ const mappings = new Map();
76
+ for (const part of parts) {
77
+ for (const [name, value] of part.defaults)
78
+ if (!defaults.has(name))
79
+ defaults.set(name, value);
80
+ for (const name of part.secretNames)
81
+ secretNames.add(name);
82
+ for (const [name, value] of part.mappings)
83
+ if (!mappings.has(name))
84
+ mappings.set(name, value);
85
+ }
86
+ return { defaults, secretNames, mappings };
87
+ }
88
+ const sharedResolutionLimit = "Files in one audit share variable defaults, locals, and parameter defaults. join, Fn::Join, Fn::FindInMap, format, and a simple Fn::Sub resolve when every piece is a known literal. Other functions and module sources do not. A finding names var, local, or parameter when the value came from one.";
89
+ function presentScan(parsed, findings, checked, options) {
90
+ const visible = filterFindings(findings, options);
31
91
  const limit = options.maxFindings ?? 100;
92
+ const sharesResolution = parsed.format === "terraform" || parsed.format === "cloudformation";
32
93
  return {
33
94
  status: "completed",
34
95
  engineVersion: VERSION,
35
96
  format: parsed.format,
36
97
  focus: options.focus ?? "general",
37
- summary: `Scanned ${parsed.resources.length} resources; ${findings.length} findings match the selected filters.`,
98
+ summary: `Scanned ${parsed.resources.length} resources; ${visible.length} findings match the selected filters.`,
38
99
  parsedResourceCount: parsed.resources.length,
39
100
  resources: parsed.resources.map((r) => ({
40
101
  id: r.id,
41
102
  type: r.type,
42
103
  location: r.locate(),
43
104
  })),
44
- findings: findings.slice(0, limit),
45
- totals: summarize(findings),
46
- totalFindings: findings.length,
47
- truncated: findings.length > limit,
48
- rulesEvaluated: [...result.checked].sort(),
49
- warnings: parsed.warnings,
105
+ findings: visible.slice(0, limit),
106
+ totals: summarize(visible),
107
+ totalFindings: visible.length,
108
+ truncated: visible.length > limit,
109
+ rulesEvaluated: [...checked].sort(),
110
+ warnings: [...new Set(parsed.warnings)],
50
111
  limitations: [
51
112
  "Static configuration review only. No cloud credentials, deployments, external modules, or runtime state are inspected.",
52
113
  "Resource-specific checks cover Kubernetes workloads, RBAC and Secrets, plus AWS networking, IAM, S3, RDS, EC2 and EBS. Other resources receive literal credential and URL checks only.",
@@ -55,11 +116,61 @@ export function scanIac(content, options = {}) {
55
116
  "HCL locations point to resource declarations; property paths identify the affected setting.",
56
117
  ]
57
118
  : []),
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
- : []),
119
+ ...(sharesResolution ? [sharedResolutionLimit] : []),
63
120
  ],
64
121
  };
65
122
  }
123
+ function scanDocument(content, options) {
124
+ const parsed = parseIac(content, options.format);
125
+ const result = resourceFindings(parsed.resources, parsed.format);
126
+ return presentScan(parsed, result.findings, result.checked, options);
127
+ }
128
+ function scanMarked(parts, options) {
129
+ const tables = mergeTables(parts.map((part) => readCloudFormationTables(part.text)));
130
+ const parsedParts = [];
131
+ let lastError;
132
+ for (const part of parts) {
133
+ if (!part.text.trim())
134
+ continue;
135
+ try {
136
+ parsedParts.push({ name: part.name, parsed: parseIac(part.text, "auto", tables) });
137
+ }
138
+ catch (error) {
139
+ lastError = error;
140
+ }
141
+ }
142
+ if (parsedParts.length === 0) {
143
+ if (lastError instanceof Error)
144
+ throw lastError;
145
+ throw new ScanInputError("Content must not be empty or whitespace.");
146
+ }
147
+ const literals = terraformLiterals(parsedParts.flatMap((part) => part.parsed.resources));
148
+ const resources = [];
149
+ const findings = [];
150
+ const checked = new Set();
151
+ const warnings = [];
152
+ const formats = new Set();
153
+ for (const part of parsedParts) {
154
+ const labeled = part.parsed.resources.map((resource) => labelResource(resource, part.name));
155
+ const result = resourceFindings(labeled, part.parsed.format, literals);
156
+ resources.push(...labeled);
157
+ findings.push(...result.findings);
158
+ for (const id of result.checked)
159
+ checked.add(id);
160
+ warnings.push(...part.parsed.warnings);
161
+ formats.add(part.parsed.format);
162
+ }
163
+ const format = formats.size === 1 ? [...formats][0] : parsedParts[0].parsed.format;
164
+ const presented = presentScan({ format, resources, warnings }, findings, checked, options);
165
+ if (formats.has("terraform") || formats.has("cloudformation")) {
166
+ if (!presented.limitations.includes(sharedResolutionLimit))
167
+ presented.limitations.push(sharedResolutionLimit);
168
+ }
169
+ return presented;
170
+ }
171
+ export function scanIac(content, options = {}) {
172
+ const parts = splitSubmittedFiles(content);
173
+ if (!parts)
174
+ return scanDocument(content, options);
175
+ return scanMarked(parts, options);
176
+ }
package/dist/server.js CHANGED
@@ -85,12 +85,13 @@ export function createServer(remote = false) {
85
85
  "tfvars",
86
86
  "serverless",
87
87
  "iam",
88
+ "package",
88
89
  "application",
89
90
  "auto",
90
91
  ])
91
92
  .optional()
92
93
  .default("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."),
94
+ .describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, serverless, iam, package, application, auto."),
94
95
  },
95
96
  }, async ({ content, focus, format, minSeverity, maxFindings }) => {
96
97
  const botHints = fetchPublicBotHints();
@@ -138,7 +139,7 @@ export function createServer(remote = false) {
138
139
  description: (name === "scan_iac_stub"
139
140
  ? "Compatibility alias for scan_iac; runs the full scanner. "
140
141
  : "") +
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.",
142
+ "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, a standalone IAM policy, and package.json. 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.",
142
143
  annotations: {
143
144
  readOnlyHint: true,
144
145
  destructiveHint: false,
@@ -146,7 +147,7 @@ export function createServer(remote = false) {
146
147
  },
147
148
  _meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
148
149
  inputSchema: {
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."),
150
+ 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, a standalone IAM policy, and package.json."),
150
151
  format: z
151
152
  .enum([
152
153
  "terraform",
@@ -166,6 +167,7 @@ export function createServer(remote = false) {
166
167
  "tfvars",
167
168
  "serverless",
168
169
  "iam",
170
+ "package",
169
171
  "auto",
170
172
  ])
171
173
  .default("auto"),
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.14";
1
+ export declare const VERSION = "0.6.16";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.14";
1
+ export const VERSION = "0.6.16";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skaleagents/swarm",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"