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