@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.
- package/dist/iac/parse.d.ts +10 -2
- package/dist/iac/parse.js +296 -21
- package/dist/iac/rules.d.ts +7 -1
- package/dist/iac/rules.js +359 -46
- package/dist/iac/scan.d.ts +1 -1
- package/dist/iac/scan.js +129 -18
- package/dist/server.js +5 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/iac/parse.d.ts
CHANGED
|
@@ -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
|
|
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 = "
|
|
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
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
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("{"))
|
package/dist/iac/rules.d.ts
CHANGED
|
@@ -9,7 +9,13 @@ type Rule = {
|
|
|
9
9
|
remediation: string;
|
|
10
10
|
};
|
|
11
11
|
export declare const rules: Record<string, Rule>;
|
|
12
|
-
export
|
|
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
|
|
44
|
-
remediation: "List the required actions
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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|
|
|
731
|
-
(
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
753
|
-
|
|
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]);
|
package/dist/iac/scan.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
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; ${
|
|
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:
|
|
45
|
-
totals: summarize(
|
|
46
|
-
totalFindings:
|
|
47
|
-
truncated:
|
|
48
|
-
rulesEvaluated: [...
|
|
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
|
-
...(
|
|
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,
|
|
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,
|
|
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.
|
|
1
|
+
export declare const VERSION = "0.6.16";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.16";
|