@skaleagents/swarm 0.6.15 → 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,7 +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;
36
42
  export declare function terraformJsonencodeValue(expression: string): unknown | undefined;
37
43
  export declare function looksLikeServerless(content: string): boolean;
44
+ export declare function looksLikePackage(content: string): boolean;
38
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. A string of those references and format of known literals are resolved. Other functions and 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. A simple Fn::Sub of those parameters is resolved. Other 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();
@@ -1459,6 +1531,7 @@ function cloudFormationSubTemplate(value) {
1459
1531
  function substituteKnownParameters(template, parameters) {
1460
1532
  let unresolved = false;
1461
1533
  let secret = false;
1534
+ const names = [];
1462
1535
  const text = template.replace(/\$\{(!?)([^}]*)\}/g, (full, literalMark, rawName) => {
1463
1536
  if (literalMark === "!")
1464
1537
  return `\${${rawName}}`;
@@ -1467,30 +1540,70 @@ function substituteKnownParameters(template, parameters) {
1467
1540
  unresolved = true;
1468
1541
  return full;
1469
1542
  }
1543
+ names.push(name);
1470
1544
  if (parameters.secretNames.has(name))
1471
1545
  secret = true;
1472
1546
  return String(parameters.defaults.get(name));
1473
1547
  });
1474
1548
  if (unresolved)
1475
1549
  return undefined;
1476
- return { text, secret };
1550
+ return { text, secret, names };
1477
1551
  }
1478
- function resolveCloudFormationRefs(value, parameters) {
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) {
1479
1585
  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
- });
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
+ }
1490
1603
  }
1491
1604
  return object(resolved);
1492
1605
  }
1493
- function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1606
+ function resolveCloudFormationValue(value, parameters, mappings, path, secretPaths, origins) {
1494
1607
  const ref = cloudFormationRefName(value);
1495
1608
  if (ref) {
1496
1609
  if (!parameters.defaults.has(ref))
@@ -1500,6 +1613,7 @@ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1500
1613
  typeof literal === "string" &&
1501
1614
  literal.trim() !== "")
1502
1615
  secretPaths.push(path);
1616
+ origins.set(path.join("."), `parameter ${ref}`);
1503
1617
  return literal;
1504
1618
  }
1505
1619
  const subTemplate = cloudFormationSubTemplate(value);
@@ -1509,18 +1623,46 @@ function resolveCloudFormationValue(value, parameters, path, secretPaths) {
1509
1623
  return value;
1510
1624
  if (substituted.secret)
1511
1625
  secretPaths.push(path);
1626
+ recordSingleOrigin(origins, path, substituted.names.map((name) => `parameter ${name}`));
1512
1627
  return substituted.text;
1513
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
+ }
1514
1656
  if (cloudFormationIntrinsic(value))
1515
1657
  return value;
1516
1658
  if (Array.isArray(value))
1517
- 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));
1518
1660
  if (value && typeof value === "object") {
1519
1661
  const out = {};
1520
1662
  for (const [key, child] of Object.entries(value)) {
1521
- if (key === "__referencedSecrets")
1663
+ if (key === "__referencedSecrets" || key === "__valueOrigins")
1522
1664
  continue;
1523
- out[key] = resolveCloudFormationValue(child, parameters, [...path, key], secretPaths);
1665
+ out[key] = resolveCloudFormationValue(child, parameters, mappings, [...path, key], secretPaths, origins);
1524
1666
  }
1525
1667
  return out;
1526
1668
  }
@@ -1558,6 +1700,86 @@ export function looksLikeServerless(content) {
1558
1700
  return false;
1559
1701
  return topLevelKey(content, "functions") || topLevelKey(content, "plugins");
1560
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
+ }
1561
1783
  export function looksLikeIamPolicy(content) {
1562
1784
  const trimmed = content.trim();
1563
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
@@ -372,13 +372,31 @@ export const rules = {
372
372
  detail: "A Serverless Framework environment entry assigns a non-empty literal to a secret-like name.",
373
373
  remediation: "Reference the value with a Serverless variable such as ${env:TOKEN}. Do not commit a literal secret.",
374
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
+ },
375
389
  };
376
- export function resourceFindings(resources, format) {
390
+ export function terraformLiterals(resources) {
391
+ return collectSameFileLiterals(resources);
392
+ }
393
+ export function resourceFindings(resources, format, shared) {
377
394
  const findings = [];
378
395
  const checked = new Set();
379
- const sameFile = collectSameFileLiterals(resources);
396
+ const sameFile = shared ?? collectSameFileLiterals(resources);
380
397
  const suppressedSecrets = new WeakMap();
381
398
  const moduleSecrets = new WeakMap();
399
+ const valueOrigins = new WeakMap();
382
400
  if (format === "terraform") {
383
401
  for (const resource of resources) {
384
402
  if (resource.type === "terraform:variable" ||
@@ -387,18 +405,22 @@ export function resourceFindings(resources, format) {
387
405
  continue;
388
406
  const suppressed = new Set();
389
407
  const referenced = new Set();
390
- resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced);
408
+ const origins = new Map();
409
+ resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced, origins);
391
410
  suppressedSecrets.set(resource, suppressed);
392
411
  moduleSecrets.set(resource, referenced);
412
+ valueOrigins.set(resource, origins);
393
413
  }
394
414
  }
395
415
  for (const resource of resources) {
416
+ const origins = valueOrigins.get(resource) ?? cloudFormationOrigins(resource);
396
417
  const check = (id, condition, path = []) => {
397
418
  checked.add(id);
398
419
  if (condition)
399
420
  findings.push({
400
421
  ruleId: id,
401
422
  ...rules[id],
423
+ detail: `${rules[id].detail}${originSentence(origins, path)}`,
402
424
  resource: resource.id,
403
425
  location: resource.locate(path),
404
426
  });
@@ -438,6 +460,10 @@ export function resourceFindings(resources, format) {
438
460
  check("NET001", resource.value.openSourceIp === true, ["Condition"]);
439
461
  continue;
440
462
  }
463
+ if (format === "package") {
464
+ checkPackage(resource, check);
465
+ continue;
466
+ }
441
467
  if (resource.type === "terraform:local") {
442
468
  checkTerraformLiterals(resource.value, "local", check, []);
443
469
  continue;
@@ -592,6 +618,7 @@ function resolveInterpolationString(value, sameFile) {
592
618
  const trimmed = value.trim();
593
619
  if (!interpolationOnly.test(trimmed))
594
620
  return undefined;
621
+ const sources = [];
595
622
  let count = 0;
596
623
  let failed = false;
597
624
  const text = trimmed.replace(interpolationPiece, (_full, kind, name) => {
@@ -601,11 +628,12 @@ function resolveInterpolationString(value, sameFile) {
601
628
  failed = true;
602
629
  return "";
603
630
  }
631
+ sources.push(`${kind}.${name}`);
604
632
  return part;
605
633
  });
606
634
  if (failed || count < 2)
607
635
  return undefined;
608
- return text;
636
+ return { text, sources };
609
637
  }
610
638
  function splitCallArguments(source) {
611
639
  const args = [];
@@ -668,14 +696,27 @@ function unquoteHcl(token) {
668
696
  }
669
697
  function resolveFormatArgument(token, sameFile) {
670
698
  const literal = unquoteHcl(token);
671
- if (literal !== undefined)
672
- return literal;
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
+ }
673
711
  if (/^-?\d+(?:\.\d+)?$/.test(token) || token === "true" || token === "false")
674
- return token;
712
+ return { text: token };
675
713
  const ref = /^(var|local)\.([A-Za-z_][A-Za-z0-9_]*)$/.exec(token);
676
714
  if (!ref)
677
715
  return undefined;
678
- return sameFileText(ref[1], ref[2], sameFile);
716
+ const text = sameFileText(ref[1], ref[2], sameFile);
717
+ if (text === undefined)
718
+ return undefined;
719
+ return { text, source: `${ref[1]}.${ref[2]}` };
679
720
  }
680
721
  function applyPercentS(format, args) {
681
722
  let index = 0;
@@ -707,13 +748,59 @@ function resolveFormatCall(value, sameFile) {
707
748
  if (!args || args.length === 0)
708
749
  return undefined;
709
750
  const resolved = [];
751
+ const sources = [];
710
752
  for (const arg of args) {
711
753
  const part = resolveFormatArgument(arg, sameFile);
712
754
  if (part === undefined)
713
755
  return undefined;
714
- resolved.push(part);
756
+ resolved.push(part.text);
757
+ if (part.source)
758
+ sources.push(part.source);
715
759
  }
716
- return applyPercentS(resolved[0], resolved.slice(1));
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] : [] };
717
804
  }
718
805
  function noteResolvedLiteral(literal, path, sameFile, suppressed, referencedSecrets) {
719
806
  if (literal.trim() === "")
@@ -758,7 +845,12 @@ function collectSameFileLiterals(resources) {
758
845
  }
759
846
  return { vars, locals, declaredSecrets };
760
847
  }
761
- 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) {
762
854
  if (typeof value === "string") {
763
855
  const match = exactSameFileRef.exec(value.trim());
764
856
  if (match) {
@@ -767,35 +859,67 @@ function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecret
767
859
  return value;
768
860
  if (typeof literal === "string")
769
861
  noteResolvedLiteral(literal, path, sameFile, suppressed, referencedSecrets);
862
+ origins.set(path.join("."), `${match[1]}.${match[2]}`);
770
863
  return literal;
771
864
  }
772
865
  const concatenated = resolveInterpolationString(value, sameFile);
773
866
  if (concatenated !== undefined) {
774
- noteResolvedLiteral(concatenated, path, sameFile, suppressed, referencedSecrets);
775
- return concatenated;
867
+ noteResolvedLiteral(concatenated.text, path, sameFile, suppressed, referencedSecrets);
868
+ recordResolvedSources(origins, path, concatenated.sources);
869
+ return concatenated.text;
776
870
  }
777
871
  const formatted = resolveFormatCall(value, sameFile);
778
872
  if (formatted !== undefined) {
779
- noteResolvedLiteral(formatted, path, sameFile, suppressed, referencedSecrets);
780
- return formatted;
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;
781
882
  }
782
883
  return value;
783
884
  }
784
885
  if (Array.isArray(value)) {
785
886
  value.forEach((item, index) => {
786
- value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets);
887
+ value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets, origins);
787
888
  });
788
889
  return value;
789
890
  }
790
891
  if (value && typeof value === "object") {
791
892
  for (const [key, child] of Object.entries(value)) {
792
- if (key === "__referencedSecrets")
893
+ if (key === "__referencedSecrets" || key === "__valueOrigins")
793
894
  continue;
794
- value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets);
895
+ value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets, origins);
795
896
  }
796
897
  }
797
898
  return value;
798
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
+ }
799
923
  function dedupeSecretFindings(findings) {
800
924
  const seen = new Set();
801
925
  return findings.filter((finding) => {
@@ -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.15";
1
+ export declare const VERSION = "0.6.16";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.6.15";
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.15",
3
+ "version": "0.6.16",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"