@skaleagents/swarm 0.6.12 → 0.6.14
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 +3 -1
- package/dist/iac/parse.js +379 -6
- package/dist/iac/rules.js +149 -7
- package/dist/iac/scan.js +5 -0
- package/dist/server.js +7 -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";
|
|
1
|
+
export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github" | "helm" | "ansible" | "bicep" | "arm" | "gitlab" | "azure-pipelines" | "cloudbuild" | "tfvars" | "serverless" | "iam";
|
|
2
2
|
export type Path = (string | number)[];
|
|
3
3
|
export type Location = {
|
|
4
4
|
line: number;
|
|
@@ -33,3 +33,5 @@ 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 looksLikeServerless(content: string): boolean;
|
|
37
|
+
export declare function looksLikeIamPolicy(content: string): boolean;
|
package/dist/iac/parse.js
CHANGED
|
@@ -30,6 +30,8 @@ export function looksLikeIac(content) {
|
|
|
30
30
|
const value = object(JSON.parse(content));
|
|
31
31
|
if (value.resource || value.Resources || (value.apiVersion && value.kind))
|
|
32
32
|
return true;
|
|
33
|
+
if (looksLikeIamPolicy(content))
|
|
34
|
+
return true;
|
|
33
35
|
}
|
|
34
36
|
catch {
|
|
35
37
|
/* Other format detection still applies to incomplete input. */
|
|
@@ -48,7 +50,9 @@ export function looksLikeIac(content) {
|
|
|
48
50
|
looksLikeBicep(content) ||
|
|
49
51
|
looksLikeArm(content) ||
|
|
50
52
|
looksLikePipeline(content) !== undefined ||
|
|
51
|
-
looksLikeTfvars(content)
|
|
53
|
+
looksLikeTfvars(content) ||
|
|
54
|
+
looksLikeServerless(content) ||
|
|
55
|
+
looksLikeIamPolicy(content));
|
|
52
56
|
}
|
|
53
57
|
export function looksLikePulumiProgram(content) {
|
|
54
58
|
return /(?:^|\n)\s*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
|
|
@@ -135,6 +139,7 @@ export function looksLikePulumiYaml(content) {
|
|
|
135
139
|
return (/^\s*resources\s*:/m.test(content) &&
|
|
136
140
|
/^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
|
|
137
141
|
}
|
|
142
|
+
const terraformExpressionWarning = "Same-file variable and local literals are resolved. Functions and other expressions are not evaluated.";
|
|
138
143
|
export function parseIac(content, requested = "auto") {
|
|
139
144
|
if (!content.trim())
|
|
140
145
|
throw new ScanInputError("Content must not be empty or whitespace.");
|
|
@@ -156,6 +161,14 @@ export function parseIac(content, requested = "auto") {
|
|
|
156
161
|
(requested === "auto" && looksLikeHelm(content))) {
|
|
157
162
|
return parseHelm(content);
|
|
158
163
|
}
|
|
164
|
+
if (requested === "serverless" ||
|
|
165
|
+
(requested === "auto" && looksLikeServerless(content))) {
|
|
166
|
+
return parseServerless(content);
|
|
167
|
+
}
|
|
168
|
+
if (requested === "iam" ||
|
|
169
|
+
(requested === "auto" && looksLikeIamPolicy(content))) {
|
|
170
|
+
return parseIamPolicy(content);
|
|
171
|
+
}
|
|
159
172
|
const pipeline = requested === "gitlab" ||
|
|
160
173
|
requested === "azure-pipelines" ||
|
|
161
174
|
requested === "cloudbuild"
|
|
@@ -257,8 +270,29 @@ export function parseIac(content, requested = "auto") {
|
|
|
257
270
|
}
|
|
258
271
|
if (object(data).module)
|
|
259
272
|
warnings.push("External modules are not expanded. The module body was not loaded, and the module source is not fetched or evaluated.");
|
|
273
|
+
const localBlocks = terraformLocalBlocks(data);
|
|
274
|
+
const localDeclarations = [
|
|
275
|
+
...searchable.matchAll(/\blocals\s*(?:"[^"]+"\s*)?\{/g),
|
|
276
|
+
];
|
|
277
|
+
localBlocks.forEach((block, index) => {
|
|
278
|
+
const offset = localDeclarations[index]?.index ?? 0;
|
|
279
|
+
const before = content.slice(0, offset);
|
|
280
|
+
resources.push({
|
|
281
|
+
id: `locals.${index + 1}`,
|
|
282
|
+
type: "terraform:local",
|
|
283
|
+
value: block,
|
|
284
|
+
locate: (path = []) => ({
|
|
285
|
+
line: before.split("\n").length,
|
|
286
|
+
column: offset - before.lastIndexOf("\n"),
|
|
287
|
+
path: ["locals", index, ...path].join("."),
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
});
|
|
260
291
|
if (JSON.stringify(data).includes("${"))
|
|
261
|
-
warnings.push(
|
|
292
|
+
warnings.push(terraformExpressionWarning);
|
|
293
|
+
if (localBlocks.length > 0 &&
|
|
294
|
+
!warnings.some((warning) => warning.includes("expressions are not evaluated")))
|
|
295
|
+
warnings.push(terraformExpressionWarning);
|
|
262
296
|
if (!resources.some((item) => item.type !== "terraform:module" && item.type !== "terraform:variable"))
|
|
263
297
|
warnings.push("No resource declarations found. Data sources and outputs are not scanned as resources.");
|
|
264
298
|
return { format: "terraform", resources, warnings };
|
|
@@ -378,11 +412,12 @@ export function parseIac(content, requested = "auto") {
|
|
|
378
412
|
Array.isArray(root.Resources) ||
|
|
379
413
|
typeof root.Resources !== "object")
|
|
380
414
|
throw new ScanInputError("CloudFormation requires a Resources mapping.");
|
|
415
|
+
const parameters = cloudFormationParameterLiterals(root);
|
|
381
416
|
for (const [name, raw] of Object.entries(object(root.Resources))) {
|
|
382
417
|
const value = object(raw);
|
|
383
418
|
if (typeof value.Type !== "string")
|
|
384
419
|
throw new ScanInputError("Each CloudFormation resource requires a Type.");
|
|
385
|
-
add(name, value.Type, value, ["Resources", name]);
|
|
420
|
+
add(name, value.Type, resolveCloudFormationRefs(value, parameters), ["Resources", name]);
|
|
386
421
|
}
|
|
387
422
|
}
|
|
388
423
|
else if (format === "terraform") {
|
|
@@ -430,10 +465,13 @@ export function parseIac(content, requested = "auto") {
|
|
|
430
465
|
}
|
|
431
466
|
}
|
|
432
467
|
if (!format)
|
|
433
|
-
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,
|
|
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.");
|
|
434
469
|
if (!resources.length)
|
|
435
470
|
warnings.push("No resources found in the submitted document.");
|
|
436
|
-
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. Functions are not evaluated.");
|
|
474
|
+
else if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
|
|
437
475
|
warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
|
|
438
476
|
return { format, resources, warnings };
|
|
439
477
|
}
|
|
@@ -693,7 +731,7 @@ function cloudBuildShape(content) {
|
|
|
693
731
|
return /^\s*-\s*name\s*:/m.test(content);
|
|
694
732
|
}
|
|
695
733
|
function looksLikePipeline(content) {
|
|
696
|
-
if (stolenPipelineInput(content))
|
|
734
|
+
if (stolenPipelineInput(content) || looksLikeServerless(content))
|
|
697
735
|
return undefined;
|
|
698
736
|
const visible = maskIaCComments(content);
|
|
699
737
|
if (azurePipelineShape(visible))
|
|
@@ -1369,3 +1407,338 @@ function parseAzure(content, format) {
|
|
|
1369
1407
|
warnings: [azureExpressionWarning],
|
|
1370
1408
|
};
|
|
1371
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 resolveCloudFormationRefs(value, parameters) {
|
|
1449
|
+
const secretPaths = [];
|
|
1450
|
+
const resolved = resolveCloudFormationValue(value, parameters, [], secretPaths);
|
|
1451
|
+
if (secretPaths.length > 0 &&
|
|
1452
|
+
resolved &&
|
|
1453
|
+
typeof resolved === "object" &&
|
|
1454
|
+
!Array.isArray(resolved)) {
|
|
1455
|
+
Object.defineProperty(resolved, "__referencedSecrets", {
|
|
1456
|
+
enumerable: false,
|
|
1457
|
+
configurable: true,
|
|
1458
|
+
value: secretPaths,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
return object(resolved);
|
|
1462
|
+
}
|
|
1463
|
+
function resolveCloudFormationValue(value, parameters, path, secretPaths) {
|
|
1464
|
+
const ref = cloudFormationRefName(value);
|
|
1465
|
+
if (ref) {
|
|
1466
|
+
if (!parameters.defaults.has(ref))
|
|
1467
|
+
return value;
|
|
1468
|
+
const literal = parameters.defaults.get(ref);
|
|
1469
|
+
if (parameters.secretNames.has(ref) &&
|
|
1470
|
+
typeof literal === "string" &&
|
|
1471
|
+
literal.trim() !== "")
|
|
1472
|
+
secretPaths.push(path);
|
|
1473
|
+
return literal;
|
|
1474
|
+
}
|
|
1475
|
+
if (cloudFormationIntrinsic(value))
|
|
1476
|
+
return value;
|
|
1477
|
+
if (Array.isArray(value))
|
|
1478
|
+
return value.map((item, index) => resolveCloudFormationValue(item, parameters, [...path, index], secretPaths));
|
|
1479
|
+
if (value && typeof value === "object") {
|
|
1480
|
+
const out = {};
|
|
1481
|
+
for (const [key, child] of Object.entries(value)) {
|
|
1482
|
+
if (key === "__referencedSecrets")
|
|
1483
|
+
continue;
|
|
1484
|
+
out[key] = resolveCloudFormationValue(child, parameters, [...path, key], secretPaths);
|
|
1485
|
+
}
|
|
1486
|
+
return out;
|
|
1487
|
+
}
|
|
1488
|
+
return value;
|
|
1489
|
+
}
|
|
1490
|
+
function terraformLocalBlocks(data) {
|
|
1491
|
+
const raw = object(data).locals;
|
|
1492
|
+
if (Array.isArray(raw))
|
|
1493
|
+
return raw.map((block) => object(block));
|
|
1494
|
+
if (raw && typeof raw === "object") {
|
|
1495
|
+
return Object.values(object(raw)).flatMap((blocks) => array(blocks).map((block) => object(block)));
|
|
1496
|
+
}
|
|
1497
|
+
return [];
|
|
1498
|
+
}
|
|
1499
|
+
export function looksLikeServerless(content) {
|
|
1500
|
+
if (looksLikeGithubWorkflow(content) || looksLikeCompose(content))
|
|
1501
|
+
return false;
|
|
1502
|
+
if (topLevelKey(content, "apiVersion") && topLevelKey(content, "kind"))
|
|
1503
|
+
return false;
|
|
1504
|
+
if (!topLevelKey(content, "service") || !topLevelKey(content, "provider"))
|
|
1505
|
+
return false;
|
|
1506
|
+
return topLevelKey(content, "functions") || topLevelKey(content, "plugins");
|
|
1507
|
+
}
|
|
1508
|
+
export function looksLikeIamPolicy(content) {
|
|
1509
|
+
const trimmed = content.trim();
|
|
1510
|
+
if (!trimmed.startsWith("{"))
|
|
1511
|
+
return false;
|
|
1512
|
+
let value;
|
|
1513
|
+
try {
|
|
1514
|
+
value = JSON.parse(trimmed);
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
const root = object(value);
|
|
1520
|
+
if (typeof root.AWSTemplateFormatVersion === "string")
|
|
1521
|
+
return false;
|
|
1522
|
+
if ("resource" in root)
|
|
1523
|
+
return false;
|
|
1524
|
+
const version = root.Version ?? root.version;
|
|
1525
|
+
if (typeof version !== "string" || !version.includes("2012-10-17"))
|
|
1526
|
+
return false;
|
|
1527
|
+
const statement = root.Statement ?? root.statement;
|
|
1528
|
+
return Array.isArray(statement);
|
|
1529
|
+
}
|
|
1530
|
+
const serverlessWarning = "The Serverless Framework config is not executed. Other condition operators are not evaluated.";
|
|
1531
|
+
const iamPolicyWarning = "The IAM policy is not executed. Other condition operators are not evaluated.";
|
|
1532
|
+
function serverlessShapeError() {
|
|
1533
|
+
return "Serverless Framework config requires top-level service and provider, plus functions or plugins.";
|
|
1534
|
+
}
|
|
1535
|
+
function iamShapeError() {
|
|
1536
|
+
return "An IAM policy requires Version 2012-10-17 and a Statement array.";
|
|
1537
|
+
}
|
|
1538
|
+
function effectAllows(body) {
|
|
1539
|
+
const effect = body.Effect ?? body.effect;
|
|
1540
|
+
if (effect == null || String(effect).trim() === "")
|
|
1541
|
+
return true;
|
|
1542
|
+
return String(effect).trim().toLowerCase() === "allow";
|
|
1543
|
+
}
|
|
1544
|
+
const openSourceAddresses = new Set(["0.0.0.0/0", "::/0", "*"]);
|
|
1545
|
+
function conditionValueIsOpen(value) {
|
|
1546
|
+
if (typeof value === "string")
|
|
1547
|
+
return openSourceAddresses.has(value.trim());
|
|
1548
|
+
if (Array.isArray(value))
|
|
1549
|
+
return value.some((item) => conditionValueIsOpen(item));
|
|
1550
|
+
return false;
|
|
1551
|
+
}
|
|
1552
|
+
function sourceAddressAllowsEveryone(body) {
|
|
1553
|
+
if (!effectAllows(body))
|
|
1554
|
+
return false;
|
|
1555
|
+
const condition = object(body.Condition ?? body.condition);
|
|
1556
|
+
let ipAddress;
|
|
1557
|
+
for (const [key, value] of Object.entries(condition)) {
|
|
1558
|
+
if (key.toLowerCase() === "ipaddress") {
|
|
1559
|
+
ipAddress = value;
|
|
1560
|
+
break;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
if (!ipAddress || typeof ipAddress !== "object" || Array.isArray(ipAddress))
|
|
1564
|
+
return false;
|
|
1565
|
+
return Object.values(object(ipAddress)).some((value) => conditionValueIsOpen(value));
|
|
1566
|
+
}
|
|
1567
|
+
function actionIsOnlyWildcard(body) {
|
|
1568
|
+
const action = body.Action ?? body.action ?? body.actions ?? body.Actions;
|
|
1569
|
+
if (typeof action === "string")
|
|
1570
|
+
return action.trim() === "*";
|
|
1571
|
+
return (Array.isArray(action) &&
|
|
1572
|
+
action.length > 0 &&
|
|
1573
|
+
action.every((item) => typeof item === "string" && item.trim() === "*"));
|
|
1574
|
+
}
|
|
1575
|
+
function serverlessReference(value) {
|
|
1576
|
+
return /^\$\{[\s\S]+\}$/.test(value.trim());
|
|
1577
|
+
}
|
|
1578
|
+
function yamlLocation(doc, lines, path) {
|
|
1579
|
+
const node = doc.getIn(path, true);
|
|
1580
|
+
const position = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
|
|
1581
|
+
return {
|
|
1582
|
+
line: position.line,
|
|
1583
|
+
column: position.col,
|
|
1584
|
+
path: path.join("."),
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
function statementGroups(root) {
|
|
1588
|
+
const groups = [];
|
|
1589
|
+
const seen = new Set();
|
|
1590
|
+
const add = (statements, path) => {
|
|
1591
|
+
if (!Array.isArray(statements) || seen.has(statements))
|
|
1592
|
+
return;
|
|
1593
|
+
seen.add(statements);
|
|
1594
|
+
groups.push({ path, statements });
|
|
1595
|
+
};
|
|
1596
|
+
const provider = object(root.provider);
|
|
1597
|
+
add(provider.iamRoleStatements, ["provider", "iamRoleStatements"]);
|
|
1598
|
+
add(root.iamRoleStatements, ["iamRoleStatements"]);
|
|
1599
|
+
add(object(object(provider.iam).role).statements, ["provider", "iam", "role", "statements"]);
|
|
1600
|
+
add(object(object(root.iam).role).statements, ["iam", "role", "statements"]);
|
|
1601
|
+
if (Array.isArray(provider.iam))
|
|
1602
|
+
add(provider.iam, ["provider", "iam"]);
|
|
1603
|
+
else
|
|
1604
|
+
add(object(provider.iam).statements, ["provider", "iam", "statements"]);
|
|
1605
|
+
return groups;
|
|
1606
|
+
}
|
|
1607
|
+
function parseServerless(content) {
|
|
1608
|
+
if (!looksLikeServerless(content))
|
|
1609
|
+
throw new ScanInputError(serverlessShapeError());
|
|
1610
|
+
const lines = new LineCounter();
|
|
1611
|
+
let documents;
|
|
1612
|
+
try {
|
|
1613
|
+
documents = parseAllDocuments(content, {
|
|
1614
|
+
lineCounter: lines,
|
|
1615
|
+
prettyErrors: false,
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
catch {
|
|
1619
|
+
throw new ScanInputError(serverlessShapeError());
|
|
1620
|
+
}
|
|
1621
|
+
const doc = documents.find((item) => item.toJS() != null);
|
|
1622
|
+
if (!doc || doc.errors.length)
|
|
1623
|
+
throw new ScanInputError(serverlessShapeError());
|
|
1624
|
+
let data;
|
|
1625
|
+
try {
|
|
1626
|
+
data = doc.toJS({ maxAliasCount: 0 });
|
|
1627
|
+
}
|
|
1628
|
+
catch {
|
|
1629
|
+
throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
|
|
1630
|
+
}
|
|
1631
|
+
const root = object(data);
|
|
1632
|
+
if (root.service == null ||
|
|
1633
|
+
root.provider == null ||
|
|
1634
|
+
(root.functions == null && root.plugins == null))
|
|
1635
|
+
throw new ScanInputError(serverlessShapeError());
|
|
1636
|
+
const resources = [];
|
|
1637
|
+
let iamCount = 0;
|
|
1638
|
+
for (const group of statementGroups(root)) {
|
|
1639
|
+
group.statements.forEach((statement, index) => {
|
|
1640
|
+
const body = object(statement);
|
|
1641
|
+
const wildcard = effectAllows(body) && actionIsOnlyWildcard(body);
|
|
1642
|
+
const openSourceIp = sourceAddressAllowsEveryone(body);
|
|
1643
|
+
if (!wildcard && !openSourceIp)
|
|
1644
|
+
return;
|
|
1645
|
+
iamCount += 1;
|
|
1646
|
+
const path = [...group.path, index, openSourceIp && !wildcard ? "Condition" : "Action"];
|
|
1647
|
+
resources.push({
|
|
1648
|
+
id: `serverless.iam.${iamCount}`,
|
|
1649
|
+
type: "serverless:iam",
|
|
1650
|
+
value: { wildcard, openSourceIp },
|
|
1651
|
+
locate: () => yamlLocation(doc, lines, path),
|
|
1652
|
+
});
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
if (iamCount === 0) {
|
|
1656
|
+
resources.push({
|
|
1657
|
+
id: "serverless.iam",
|
|
1658
|
+
type: "serverless:iam",
|
|
1659
|
+
value: { wildcard: false },
|
|
1660
|
+
locate: () => yamlLocation(doc, lines, ["provider", "iamRoleStatements"]),
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
const envHits = [];
|
|
1664
|
+
for (const [key, value] of Object.entries(object(object(root.provider).environment))) {
|
|
1665
|
+
if (secretVariableName(key))
|
|
1666
|
+
envHits.push({ path: ["provider", "environment", key], key, value });
|
|
1667
|
+
}
|
|
1668
|
+
for (const [name, raw] of Object.entries(object(root.functions))) {
|
|
1669
|
+
for (const [key, value] of Object.entries(object(object(raw).environment))) {
|
|
1670
|
+
if (secretVariableName(key))
|
|
1671
|
+
envHits.push({ path: ["functions", name, "environment", key], key, value });
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
if (envHits.length === 0) {
|
|
1675
|
+
resources.push({
|
|
1676
|
+
id: "serverless.environment",
|
|
1677
|
+
type: "serverless:env",
|
|
1678
|
+
value: { literal: false },
|
|
1679
|
+
locate: () => yamlLocation(doc, lines, ["provider", "environment"]),
|
|
1680
|
+
});
|
|
1681
|
+
}
|
|
1682
|
+
else {
|
|
1683
|
+
envHits.forEach((hit, index) => {
|
|
1684
|
+
const literal = typeof hit.value === "string" &&
|
|
1685
|
+
hit.value.trim() !== "" &&
|
|
1686
|
+
!serverlessReference(hit.value);
|
|
1687
|
+
resources.push({
|
|
1688
|
+
id: `serverless.env.${index + 1}`,
|
|
1689
|
+
type: "serverless:env",
|
|
1690
|
+
value: { literal },
|
|
1691
|
+
locate: () => yamlLocation(doc, lines, hit.path),
|
|
1692
|
+
});
|
|
1693
|
+
});
|
|
1694
|
+
}
|
|
1695
|
+
return { format: "serverless", resources, warnings: [serverlessWarning] };
|
|
1696
|
+
}
|
|
1697
|
+
function parseIamPolicy(content) {
|
|
1698
|
+
if (!looksLikeIamPolicy(content))
|
|
1699
|
+
throw new ScanInputError(iamShapeError());
|
|
1700
|
+
let root;
|
|
1701
|
+
try {
|
|
1702
|
+
root = object(JSON.parse(content));
|
|
1703
|
+
}
|
|
1704
|
+
catch {
|
|
1705
|
+
throw new ScanInputError(iamShapeError());
|
|
1706
|
+
}
|
|
1707
|
+
const statements = array(root.Statement ?? root.statement);
|
|
1708
|
+
const resources = [];
|
|
1709
|
+
statements.forEach((statement, index) => {
|
|
1710
|
+
const body = object(statement);
|
|
1711
|
+
const wildcard = effectAllows(body) && actionIsOnlyWildcard(body);
|
|
1712
|
+
const openSourceIp = sourceAddressAllowsEveryone(body);
|
|
1713
|
+
if (!wildcard && !openSourceIp)
|
|
1714
|
+
return;
|
|
1715
|
+
const line = iamStatementLine(content, index, openSourceIp && !wildcard ? "Condition" : "Action");
|
|
1716
|
+
resources.push({
|
|
1717
|
+
id: `iam.statement.${index + 1}`,
|
|
1718
|
+
type: "iam:statement",
|
|
1719
|
+
value: { wildcard, openSourceIp },
|
|
1720
|
+
locate: () => lineLocation(line, openSourceIp && !wildcard
|
|
1721
|
+
? `Statement.${index}.Condition`
|
|
1722
|
+
: `Statement.${index}.Action`),
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
if (!resources.length) {
|
|
1726
|
+
resources.push({
|
|
1727
|
+
id: "iam.policy",
|
|
1728
|
+
type: "iam:statement",
|
|
1729
|
+
value: { wildcard: false },
|
|
1730
|
+
locate: () => lineLocation(1, "Statement"),
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
return { format: "iam", resources, warnings: [iamPolicyWarning] };
|
|
1734
|
+
}
|
|
1735
|
+
function iamStatementLine(content, index, key = "Action") {
|
|
1736
|
+
const matches = [...content.matchAll(/"Statement"\s*:/g)];
|
|
1737
|
+
const start = matches[0]?.index ?? 0;
|
|
1738
|
+
const region = content.slice(start);
|
|
1739
|
+
const keys = [...region.matchAll(new RegExp(`"${key}"\\s*:`, "g"))];
|
|
1740
|
+
const hit = key === "Action" ? keys[index] : keys.find((item) => item.index != null);
|
|
1741
|
+
if (!hit || hit.index == null)
|
|
1742
|
+
return lineAt(content, start);
|
|
1743
|
+
return lineAt(content, start + hit.index);
|
|
1744
|
+
}
|
package/dist/iac/rules.js
CHANGED
|
@@ -351,10 +351,40 @@ export const rules = {
|
|
|
351
351
|
detail: "A Terraform variable default or tfvars assignment gives a non-empty literal to a secret-like name.",
|
|
352
352
|
remediation: "Pass the value from a secret store or leave the default empty. Do not commit a literal secret.",
|
|
353
353
|
},
|
|
354
|
+
TF002: {
|
|
355
|
+
category: "security",
|
|
356
|
+
severity: "high",
|
|
357
|
+
title: "Secret assigned in a Terraform local",
|
|
358
|
+
detail: "A Terraform local assigns a non-empty literal to a secret-like name.",
|
|
359
|
+
remediation: "Pass the value from a secret store or a variable reference. Do not commit a literal secret in locals.",
|
|
360
|
+
},
|
|
361
|
+
SLS001: {
|
|
362
|
+
category: "security",
|
|
363
|
+
severity: "high",
|
|
364
|
+
title: "Secret assigned in serverless environment",
|
|
365
|
+
detail: "A Serverless Framework environment entry assigns a non-empty literal to a secret-like name.",
|
|
366
|
+
remediation: "Reference the value with a Serverless variable such as ${env:TOKEN}. Do not commit a literal secret.",
|
|
367
|
+
},
|
|
354
368
|
};
|
|
355
369
|
export function resourceFindings(resources, format) {
|
|
356
370
|
const findings = [];
|
|
357
371
|
const checked = new Set();
|
|
372
|
+
const sameFile = collectSameFileLiterals(resources);
|
|
373
|
+
const suppressedSecrets = new WeakMap();
|
|
374
|
+
const moduleSecrets = new WeakMap();
|
|
375
|
+
if (format === "terraform") {
|
|
376
|
+
for (const resource of resources) {
|
|
377
|
+
if (resource.type === "terraform:variable" ||
|
|
378
|
+
resource.type === "terraform:local" ||
|
|
379
|
+
resource.type === "tfvars:assignment")
|
|
380
|
+
continue;
|
|
381
|
+
const suppressed = new Set();
|
|
382
|
+
const referenced = new Set();
|
|
383
|
+
resolveSameFileRefs(resource.value, sameFile, [], suppressed, referenced);
|
|
384
|
+
suppressedSecrets.set(resource, suppressed);
|
|
385
|
+
moduleSecrets.set(resource, referenced);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
358
388
|
for (const resource of resources) {
|
|
359
389
|
const check = (id, condition, path = []) => {
|
|
360
390
|
checked.add(id);
|
|
@@ -392,8 +422,21 @@ export function resourceFindings(resources, format) {
|
|
|
392
422
|
checkPipeline(resource, check);
|
|
393
423
|
continue;
|
|
394
424
|
}
|
|
425
|
+
if (format === "serverless") {
|
|
426
|
+
checkServerless(resource, check);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (format === "iam") {
|
|
430
|
+
check("IAM001", resource.value.wildcard === true, ["Action"]);
|
|
431
|
+
check("NET001", resource.value.openSourceIp === true, ["Condition"]);
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
if (resource.type === "terraform:local") {
|
|
435
|
+
checkTerraformLiterals(resource.value, "local", check, []);
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
395
438
|
if (resource.type === "terraform:module") {
|
|
396
|
-
checkTerraformLiterals(resource.value, "module", check, []);
|
|
439
|
+
checkTerraformLiterals(resource.value, "module", check, [], "", moduleSecrets.get(resource));
|
|
397
440
|
continue;
|
|
398
441
|
}
|
|
399
442
|
if (resource.type === "terraform:variable") {
|
|
@@ -410,7 +453,8 @@ export function resourceFindings(resources, format) {
|
|
|
410
453
|
const credentialName = /(?:password|passwd|secret|api[_-]?key|auth[_-]?token)$/i;
|
|
411
454
|
check("SEC001", (credentialName.test(key) ||
|
|
412
455
|
(key === "value" && credentialName.test(String(parent.name)))) &&
|
|
413
|
-
literalCredential(value)
|
|
456
|
+
literalCredential(value) &&
|
|
457
|
+
!suppressedSecrets.get(resource)?.has(path.join(".")), path);
|
|
414
458
|
check("SEC002", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/.test(value), path);
|
|
415
459
|
check("SEC003", /-----BEGIN (?:RSA |EC |OPENSSH |DSA |)PRIVATE KEY-----/.test(value), path);
|
|
416
460
|
check("NET002", [...value.matchAll(/\bhttp:\/\/[^\s"'<>]+/gi)].some((m) => externalHttp(m[0])), path);
|
|
@@ -421,8 +465,15 @@ export function resourceFindings(resources, format) {
|
|
|
421
465
|
checkCompose(resource, check);
|
|
422
466
|
else
|
|
423
467
|
checkCloud(resource, resources, format, check);
|
|
468
|
+
const referencedSecrets = Object.getOwnPropertyDescriptor(resource.value, "__referencedSecrets")?.value;
|
|
469
|
+
if (Array.isArray(referencedSecrets)) {
|
|
470
|
+
for (const secretPath of referencedSecrets) {
|
|
471
|
+
if (Array.isArray(secretPath))
|
|
472
|
+
check("SEC001", true, secretPath);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
424
475
|
}
|
|
425
|
-
return { findings, checked };
|
|
476
|
+
return { findings: dedupeSecretFindings(findings), checked };
|
|
426
477
|
}
|
|
427
478
|
function checkDockerfile(resource, check) {
|
|
428
479
|
if (resource.type === "dockerfile:user")
|
|
@@ -473,6 +524,14 @@ function checkPipeline(resource, check) {
|
|
|
473
524
|
else if (resource.type === "pipeline:privileged")
|
|
474
525
|
check("PIPE003", resource.value.present === true, ["privileged"]);
|
|
475
526
|
}
|
|
527
|
+
function checkServerless(resource, check) {
|
|
528
|
+
if (resource.type === "serverless:iam") {
|
|
529
|
+
check("IAM001", resource.value.wildcard === true, ["Action"]);
|
|
530
|
+
check("NET001", resource.value.openSourceIp === true, ["Condition"]);
|
|
531
|
+
}
|
|
532
|
+
else if (resource.type === "serverless:env")
|
|
533
|
+
check("SLS001", resource.value.literal === true, ["environment"]);
|
|
534
|
+
}
|
|
476
535
|
function terraformReference(value) {
|
|
477
536
|
const trimmed = value.trim();
|
|
478
537
|
return trimmed.includes("${") || /^var\./.test(trimmed);
|
|
@@ -488,27 +547,110 @@ function moduleCidrExempt(name) {
|
|
|
488
547
|
normalized === "destination" ||
|
|
489
548
|
normalized === "ipv6_egress");
|
|
490
549
|
}
|
|
491
|
-
function checkTerraformLiterals(value, mode, check, path, name = "") {
|
|
550
|
+
function checkTerraformLiterals(value, mode, check, path, name = "", referencedSecrets) {
|
|
492
551
|
if (typeof value === "string" || typeof value === "boolean") {
|
|
493
552
|
const open = value === "0.0.0.0/0" || value === "::/0";
|
|
494
|
-
const exempt = mode === "
|
|
553
|
+
const exempt = mode === "assignment" ? name.toLowerCase() === "egress" : moduleCidrExempt(name);
|
|
495
554
|
if (typeof value === "string")
|
|
496
555
|
check("NET001", open && name !== "" && !exempt, path);
|
|
497
556
|
if (name === "publicly_accessible")
|
|
498
557
|
check("DB001", value === true, path);
|
|
499
558
|
if (mode === "assignment" && typeof value === "string" && secretAssignmentName(name))
|
|
500
559
|
check("TF001", value.trim() !== "" && !terraformReference(value), path);
|
|
560
|
+
if (mode === "local" && typeof value === "string" && secretAssignmentName(name))
|
|
561
|
+
check("TF002", value.trim() !== "" && !terraformReference(value), path);
|
|
562
|
+
if (mode === "module" && referencedSecrets?.has(path.join(".")))
|
|
563
|
+
check("SEC001", true, path);
|
|
501
564
|
return;
|
|
502
565
|
}
|
|
503
566
|
if (Array.isArray(value)) {
|
|
504
|
-
value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name));
|
|
567
|
+
value.forEach((item, index) => checkTerraformLiterals(item, mode, check, [...path, index], name, referencedSecrets));
|
|
505
568
|
return;
|
|
506
569
|
}
|
|
507
570
|
if (value && typeof value === "object") {
|
|
508
571
|
for (const [key, child] of Object.entries(value))
|
|
509
|
-
checkTerraformLiterals(child, mode, check, [...path, key], key);
|
|
572
|
+
checkTerraformLiterals(child, mode, check, [...path, key], key, referencedSecrets);
|
|
510
573
|
}
|
|
511
574
|
}
|
|
575
|
+
const exactSameFileRef = /^\$\{(var|local)\.([A-Za-z_][A-Za-z0-9_]*)\}$/;
|
|
576
|
+
function sameFileLiteral(value) {
|
|
577
|
+
if (typeof value === "boolean" || typeof value === "number")
|
|
578
|
+
return value;
|
|
579
|
+
if (typeof value === "string" && !value.includes("${"))
|
|
580
|
+
return value;
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
function collectSameFileLiterals(resources) {
|
|
584
|
+
const vars = new Map();
|
|
585
|
+
const locals = new Map();
|
|
586
|
+
const declaredSecrets = new Set();
|
|
587
|
+
for (const resource of resources) {
|
|
588
|
+
if (resource.type === "terraform:variable") {
|
|
589
|
+
const name = String(resource.value.name ?? "");
|
|
590
|
+
const literal = sameFileLiteral(resource.value.default);
|
|
591
|
+
if (!name || literal === undefined)
|
|
592
|
+
continue;
|
|
593
|
+
vars.set(name, literal);
|
|
594
|
+
if (typeof literal === "string" && literal.trim() !== "" && secretAssignmentName(name))
|
|
595
|
+
declaredSecrets.add(literal);
|
|
596
|
+
}
|
|
597
|
+
if (resource.type === "terraform:local") {
|
|
598
|
+
for (const [name, raw] of Object.entries(resource.value)) {
|
|
599
|
+
const literal = sameFileLiteral(raw);
|
|
600
|
+
if (literal === undefined)
|
|
601
|
+
continue;
|
|
602
|
+
locals.set(name, literal);
|
|
603
|
+
if (typeof literal === "string" && literal.trim() !== "" && secretAssignmentName(name))
|
|
604
|
+
declaredSecrets.add(literal);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { vars, locals, declaredSecrets };
|
|
609
|
+
}
|
|
610
|
+
function resolveSameFileRefs(value, sameFile, path, suppressed, referencedSecrets) {
|
|
611
|
+
if (typeof value === "string") {
|
|
612
|
+
const match = exactSameFileRef.exec(value.trim());
|
|
613
|
+
if (!match)
|
|
614
|
+
return value;
|
|
615
|
+
const literal = (match[1] === "var" ? sameFile.vars : sameFile.locals).get(match[2]);
|
|
616
|
+
if (literal === undefined)
|
|
617
|
+
return value;
|
|
618
|
+
if (typeof literal === "string" && literal.trim() !== "") {
|
|
619
|
+
const key = [...path].reverse().find((part) => typeof part === "string");
|
|
620
|
+
if (sameFile.declaredSecrets.has(literal))
|
|
621
|
+
suppressed.add(path.join("."));
|
|
622
|
+
else if (typeof key === "string" && secretAssignmentName(String(key)))
|
|
623
|
+
referencedSecrets.add(path.join("."));
|
|
624
|
+
}
|
|
625
|
+
return literal;
|
|
626
|
+
}
|
|
627
|
+
if (Array.isArray(value)) {
|
|
628
|
+
value.forEach((item, index) => {
|
|
629
|
+
value[index] = resolveSameFileRefs(item, sameFile, [...path, index], suppressed, referencedSecrets);
|
|
630
|
+
});
|
|
631
|
+
return value;
|
|
632
|
+
}
|
|
633
|
+
if (value && typeof value === "object") {
|
|
634
|
+
for (const [key, child] of Object.entries(value)) {
|
|
635
|
+
if (key === "__referencedSecrets")
|
|
636
|
+
continue;
|
|
637
|
+
value[key] = resolveSameFileRefs(child, sameFile, [...path, key], suppressed, referencedSecrets);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return value;
|
|
641
|
+
}
|
|
642
|
+
function dedupeSecretFindings(findings) {
|
|
643
|
+
const seen = new Set();
|
|
644
|
+
return findings.filter((finding) => {
|
|
645
|
+
if (finding.ruleId !== "SEC001")
|
|
646
|
+
return true;
|
|
647
|
+
const key = `${finding.ruleId}|${finding.resource}|${finding.location?.path ?? ""}`;
|
|
648
|
+
if (seen.has(key))
|
|
649
|
+
return false;
|
|
650
|
+
seen.add(key);
|
|
651
|
+
return true;
|
|
652
|
+
});
|
|
653
|
+
}
|
|
512
654
|
function checkAzure(resource, check) {
|
|
513
655
|
if (resource.type === "azure:exposure")
|
|
514
656
|
check("AZ001", resource.value.present === true, ["sourceAddressPrefix"]);
|
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/server.js
CHANGED
|
@@ -83,12 +83,14 @@ export function createServer(remote = false) {
|
|
|
83
83
|
"azure-pipelines",
|
|
84
84
|
"cloudbuild",
|
|
85
85
|
"tfvars",
|
|
86
|
+
"serverless",
|
|
87
|
+
"iam",
|
|
86
88
|
"application",
|
|
87
89
|
"auto",
|
|
88
90
|
])
|
|
89
91
|
.optional()
|
|
90
92
|
.default("auto")
|
|
91
|
-
.describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, bicep, arm, gitlab, azure-pipelines, cloudbuild, tfvars, application, 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."),
|
|
92
94
|
},
|
|
93
95
|
}, async ({ content, focus, format, minSeverity, maxFindings }) => {
|
|
94
96
|
const botHints = fetchPublicBotHints();
|
|
@@ -136,7 +138,7 @@ export function createServer(remote = false) {
|
|
|
136
138
|
description: (name === "scan_iac_stub"
|
|
137
139
|
? "Compatibility alias for scan_iac; runs the full scanner. "
|
|
138
140
|
: "") +
|
|
139
|
-
"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, and
|
|
141
|
+
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, ARM templates, GitLab CI, Azure Pipelines, Cloud Build, tfvars, Serverless Framework, and a standalone IAM policy. Helm templates, Ansible facts, Bicep modules, ARM expressions, pipelines, Serverless Framework config, and IAM policies are not executed. Conditions are not evaluated. Terraform module sources are not fetched. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
|
|
140
142
|
annotations: {
|
|
141
143
|
readOnlyHint: true,
|
|
142
144
|
destructiveHint: false,
|
|
@@ -144,7 +146,7 @@ export function createServer(remote = false) {
|
|
|
144
146
|
},
|
|
145
147
|
_meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
|
|
146
148
|
inputSchema: {
|
|
147
|
-
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, and
|
|
149
|
+
content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, Ansible playbooks, Bicep files, ARM templates, GitLab CI, Azure Pipelines, Cloud Build, tfvars, Serverless Framework, and a standalone IAM policy."),
|
|
148
150
|
format: z
|
|
149
151
|
.enum([
|
|
150
152
|
"terraform",
|
|
@@ -162,6 +164,8 @@ export function createServer(remote = false) {
|
|
|
162
164
|
"azure-pipelines",
|
|
163
165
|
"cloudbuild",
|
|
164
166
|
"tfvars",
|
|
167
|
+
"serverless",
|
|
168
|
+
"iam",
|
|
165
169
|
"auto",
|
|
166
170
|
])
|
|
167
171
|
.default("auto"),
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.6.
|
|
1
|
+
export declare const VERSION = "0.6.14";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.14";
|