@skaleagents/swarm 0.6.8 → 0.6.10
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 +5 -1
- package/dist/iac/parse.js +456 -2
- package/dist/iac/rules.js +151 -0
- package/dist/server.js +11 -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";
|
|
1
|
+
export type IacFormat = "terraform" | "cloudformation" | "kubernetes" | "pulumi" | "compose" | "dockerfile" | "github" | "helm" | "ansible";
|
|
2
2
|
export type Path = (string | number)[];
|
|
3
3
|
export type Location = {
|
|
4
4
|
line: number;
|
|
@@ -24,5 +24,9 @@ export declare function array(value: unknown): unknown[];
|
|
|
24
24
|
export declare function looksLikeIac(content: string): boolean;
|
|
25
25
|
export declare function looksLikePulumiProgram(content: string): boolean;
|
|
26
26
|
export declare function looksLikeCompose(content: string): boolean;
|
|
27
|
+
export declare function looksLikeDockerfile(content: string): boolean;
|
|
28
|
+
export declare function looksLikeAnsible(content: string): boolean;
|
|
29
|
+
export declare function looksLikeHelm(content: string): boolean;
|
|
30
|
+
export declare function looksLikeGithubWorkflow(content: string): boolean;
|
|
27
31
|
export declare function looksLikePulumiYaml(content: string): boolean;
|
|
28
32
|
export declare function parseIac(content: string, requested?: IacFormat | "auto"): ParsedIac;
|
package/dist/iac/parse.js
CHANGED
|
@@ -40,7 +40,11 @@ export function looksLikeIac(content) {
|
|
|
40
40
|
/^\s*\{\s*"(?:resource|Resources|apiVersion|AWSTemplateFormatVersion)"\s*:/.test(content) ||
|
|
41
41
|
looksLikePulumiYaml(content) ||
|
|
42
42
|
looksLikePulumiProgram(content) ||
|
|
43
|
-
looksLikeCompose(content)
|
|
43
|
+
looksLikeCompose(content) ||
|
|
44
|
+
looksLikeDockerfile(content) ||
|
|
45
|
+
looksLikeGithubWorkflow(content) ||
|
|
46
|
+
looksLikeHelm(content) ||
|
|
47
|
+
looksLikeAnsible(content));
|
|
44
48
|
}
|
|
45
49
|
export function looksLikePulumiProgram(content) {
|
|
46
50
|
return /(?:^|\n)\s*(?:import\s+\*\s+as\s+pulumi\b|import\s+pulumi\b|from\s+["']@pulumi\/)/.test(content);
|
|
@@ -48,6 +52,43 @@ export function looksLikePulumiProgram(content) {
|
|
|
48
52
|
export function looksLikeCompose(content) {
|
|
49
53
|
return /^\s*services\s*:/m.test(content) && /^\s+image\s*:/m.test(content);
|
|
50
54
|
}
|
|
55
|
+
export function looksLikeDockerfile(content) {
|
|
56
|
+
return /^\s*FROM\s+\S+/m.test(content);
|
|
57
|
+
}
|
|
58
|
+
function topLevelKey(content, name) {
|
|
59
|
+
return new RegExp(`^(?:"${name}"|'${name}'|${name})\\s*:`, "m").test(content);
|
|
60
|
+
}
|
|
61
|
+
function playbookShape(content) {
|
|
62
|
+
if (topLevelKey(content, "hosts") && topLevelKey(content, "tasks"))
|
|
63
|
+
return true;
|
|
64
|
+
return /^\s*-\s+hosts\s*:/m.test(content) && /^\s*tasks\s*:/m.test(content);
|
|
65
|
+
}
|
|
66
|
+
export function looksLikeAnsible(content) {
|
|
67
|
+
if (looksLikeGithubWorkflow(content))
|
|
68
|
+
return false;
|
|
69
|
+
if (topLevelKey(content, "apiVersion") && topLevelKey(content, "kind"))
|
|
70
|
+
return false;
|
|
71
|
+
return playbookShape(content);
|
|
72
|
+
}
|
|
73
|
+
export function looksLikeHelm(content) {
|
|
74
|
+
if (looksLikeGithubWorkflow(content) || looksLikeAnsible(content))
|
|
75
|
+
return false;
|
|
76
|
+
const visible = maskNonCode(content);
|
|
77
|
+
if (!visible.includes("{{"))
|
|
78
|
+
return false;
|
|
79
|
+
return /^\s*(?:apiVersion|kind)\s*:/m.test(visible);
|
|
80
|
+
}
|
|
81
|
+
export function looksLikeGithubWorkflow(content) {
|
|
82
|
+
if (topLevelKey(content, "apiVersion") || topLevelKey(content, "kind"))
|
|
83
|
+
return false;
|
|
84
|
+
const topOn = topLevelKey(content, "on");
|
|
85
|
+
const composeOnly = topLevelKey(content, "services") &&
|
|
86
|
+
/^\s+image\s*:/m.test(content) &&
|
|
87
|
+
!topOn;
|
|
88
|
+
if (composeOnly)
|
|
89
|
+
return false;
|
|
90
|
+
return topOn && topLevelKey(content, "jobs");
|
|
91
|
+
}
|
|
51
92
|
export function looksLikePulumiYaml(content) {
|
|
52
93
|
return (/^\s*resources\s*:/m.test(content) &&
|
|
53
94
|
/^\s+type\s*:\s*[A-Za-z0-9_.-]+:\S+/m.test(content));
|
|
@@ -55,6 +96,24 @@ export function looksLikePulumiYaml(content) {
|
|
|
55
96
|
export function parseIac(content, requested = "auto") {
|
|
56
97
|
if (!content.trim())
|
|
57
98
|
throw new ScanInputError("Content must not be empty or whitespace.");
|
|
99
|
+
// Detect these before HCL or a failing YAML parse so a Dockerfile is not classified as Terraform.
|
|
100
|
+
if (requested === "dockerfile" ||
|
|
101
|
+
((requested === "auto" || requested === "terraform") &&
|
|
102
|
+
looksLikeDockerfile(content))) {
|
|
103
|
+
return parseDockerfile(content);
|
|
104
|
+
}
|
|
105
|
+
if (requested === "github" ||
|
|
106
|
+
(requested === "auto" && looksLikeGithubWorkflow(content))) {
|
|
107
|
+
return parseGithubWorkflow(content);
|
|
108
|
+
}
|
|
109
|
+
if (requested === "ansible" ||
|
|
110
|
+
(requested === "auto" && looksLikeAnsible(content))) {
|
|
111
|
+
return parseAnsible(content);
|
|
112
|
+
}
|
|
113
|
+
if (requested === "helm" ||
|
|
114
|
+
(requested === "auto" && looksLikeHelm(content))) {
|
|
115
|
+
return parseHelm(content);
|
|
116
|
+
}
|
|
58
117
|
if (requested !== "terraform" &&
|
|
59
118
|
requested !== "compose" &&
|
|
60
119
|
looksLikePulumiProgram(content) &&
|
|
@@ -271,7 +330,7 @@ export function parseIac(content, requested = "auto") {
|
|
|
271
330
|
}
|
|
272
331
|
}
|
|
273
332
|
if (!format)
|
|
274
|
-
throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, or
|
|
333
|
+
throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, Kubernetes, Pulumi, Docker Compose, a Dockerfile, a GitHub Actions workflow, a Helm template, or an Ansible playbook.");
|
|
275
334
|
if (!resources.length)
|
|
276
335
|
warnings.push("No resources found in the submitted document.");
|
|
277
336
|
if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
|
|
@@ -352,6 +411,309 @@ function parsePulumiProgram(content) {
|
|
|
352
411
|
],
|
|
353
412
|
};
|
|
354
413
|
}
|
|
414
|
+
const submittedTextWarning = "The scan reads the submitted text and does not execute the image or the workflow.";
|
|
415
|
+
function logicalDockerfileLines(content) {
|
|
416
|
+
const raw = content.split(/\r?\n/);
|
|
417
|
+
const lines = [];
|
|
418
|
+
let buffer = "";
|
|
419
|
+
let start = 1;
|
|
420
|
+
for (let index = 0; index < raw.length; index++) {
|
|
421
|
+
const current = raw[index].replace(/\s+$/, "");
|
|
422
|
+
if (!buffer)
|
|
423
|
+
start = index + 1;
|
|
424
|
+
if (current.endsWith("\\")) {
|
|
425
|
+
buffer += `${current.slice(0, -1)} `;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
buffer += current;
|
|
429
|
+
const text = buffer.trim();
|
|
430
|
+
buffer = "";
|
|
431
|
+
if (!text || text.startsWith("#"))
|
|
432
|
+
continue;
|
|
433
|
+
lines.push({ line: start, text });
|
|
434
|
+
}
|
|
435
|
+
return lines;
|
|
436
|
+
}
|
|
437
|
+
function parseFromInstruction(text) {
|
|
438
|
+
if (!/^FROM\b/i.test(text))
|
|
439
|
+
return undefined;
|
|
440
|
+
const parts = text.split(/\s+/);
|
|
441
|
+
let index = 1;
|
|
442
|
+
while (index < parts.length && parts[index].startsWith("--")) {
|
|
443
|
+
if (!parts[index].includes("="))
|
|
444
|
+
index += 1;
|
|
445
|
+
index += 1;
|
|
446
|
+
}
|
|
447
|
+
const image = parts[index];
|
|
448
|
+
if (!image)
|
|
449
|
+
return undefined;
|
|
450
|
+
const stage = /^as$/i.test(parts[index + 1] ?? "")
|
|
451
|
+
? parts[index + 2]
|
|
452
|
+
: undefined;
|
|
453
|
+
return stage ? { image, stage } : { image };
|
|
454
|
+
}
|
|
455
|
+
function imageIsUnpinned(image, stages) {
|
|
456
|
+
if (image.includes("${") ||
|
|
457
|
+
image.includes("@") ||
|
|
458
|
+
stages.has(image) ||
|
|
459
|
+
stages.has(image.toLowerCase()) ||
|
|
460
|
+
image.toLowerCase() === "scratch")
|
|
461
|
+
return false;
|
|
462
|
+
const slash = image.lastIndexOf("/");
|
|
463
|
+
const colon = image.lastIndexOf(":");
|
|
464
|
+
if (colon <= slash)
|
|
465
|
+
return true;
|
|
466
|
+
return image.slice(colon + 1) === "latest";
|
|
467
|
+
}
|
|
468
|
+
function parseUserName(text) {
|
|
469
|
+
if (!/^USER\b/i.test(text))
|
|
470
|
+
return undefined;
|
|
471
|
+
const parts = text
|
|
472
|
+
.split(/\s+/)
|
|
473
|
+
.slice(1)
|
|
474
|
+
.filter((part) => part && !part.startsWith("--"));
|
|
475
|
+
const raw = parts[0];
|
|
476
|
+
if (!raw)
|
|
477
|
+
return undefined;
|
|
478
|
+
return raw.replace(/^["']|["']$/g, "").split(":")[0];
|
|
479
|
+
}
|
|
480
|
+
function runPipesToShell(text) {
|
|
481
|
+
return (/^RUN\b/i.test(text) &&
|
|
482
|
+
/\b(?:curl|wget)\b/i.test(text) &&
|
|
483
|
+
/\|\s*(?:sudo\s+)?(?:\S*\/)?(?:ba)?sh\b/i.test(text));
|
|
484
|
+
}
|
|
485
|
+
function copySources(text) {
|
|
486
|
+
if (!/^(?:COPY|ADD)\b/i.test(text))
|
|
487
|
+
return undefined;
|
|
488
|
+
const rest = text.replace(/^(?:COPY|ADD)\b/i, "").trim();
|
|
489
|
+
if (rest.startsWith("[")) {
|
|
490
|
+
try {
|
|
491
|
+
const parsed = JSON.parse(rest);
|
|
492
|
+
if (!Array.isArray(parsed) || parsed.length < 2)
|
|
493
|
+
return [];
|
|
494
|
+
return parsed.slice(0, -1).map((item) => String(item));
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return [];
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const tokens = [];
|
|
501
|
+
for (const match of rest.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
|
|
502
|
+
const token = match[1] ?? match[2] ?? match[3];
|
|
503
|
+
if (token.startsWith("--"))
|
|
504
|
+
continue;
|
|
505
|
+
tokens.push(token);
|
|
506
|
+
}
|
|
507
|
+
if (tokens.length < 2)
|
|
508
|
+
return [];
|
|
509
|
+
return tokens.slice(0, -1);
|
|
510
|
+
}
|
|
511
|
+
function sourceIsSecret(source) {
|
|
512
|
+
const path = source.replace(/\\/g, "/").replace(/\/+$/, "");
|
|
513
|
+
const base = path.split("/").filter(Boolean).at(-1) ?? path;
|
|
514
|
+
if (base === ".env" || base === "id_rsa" || base === "id_ed25519")
|
|
515
|
+
return true;
|
|
516
|
+
if (base.endsWith(".pem"))
|
|
517
|
+
return true;
|
|
518
|
+
return base === "credentials";
|
|
519
|
+
}
|
|
520
|
+
function lineLocation(line, path) {
|
|
521
|
+
return { line, column: 1, path };
|
|
522
|
+
}
|
|
523
|
+
function parseDockerfile(content) {
|
|
524
|
+
const instructions = logicalDockerfileLines(content);
|
|
525
|
+
const froms = instructions.flatMap((instruction) => {
|
|
526
|
+
const parsed = parseFromInstruction(instruction.text);
|
|
527
|
+
return parsed ? [{ ...instruction, ...parsed }] : [];
|
|
528
|
+
});
|
|
529
|
+
if (!froms.length)
|
|
530
|
+
throw new ScanInputError("A Dockerfile requires a FROM instruction.");
|
|
531
|
+
const stages = new Set();
|
|
532
|
+
const resources = [];
|
|
533
|
+
froms.forEach((from, index) => {
|
|
534
|
+
resources.push({
|
|
535
|
+
id: `dockerfile.from.${index + 1}`,
|
|
536
|
+
type: "dockerfile:from",
|
|
537
|
+
value: { image: from.image, unpinned: imageIsUnpinned(from.image, stages) },
|
|
538
|
+
locate: () => lineLocation(from.line, "image"),
|
|
539
|
+
});
|
|
540
|
+
if (from.stage) {
|
|
541
|
+
stages.add(from.stage);
|
|
542
|
+
stages.add(from.stage.toLowerCase());
|
|
543
|
+
}
|
|
544
|
+
});
|
|
545
|
+
let lastUser;
|
|
546
|
+
for (const instruction of instructions) {
|
|
547
|
+
const name = parseUserName(instruction.text);
|
|
548
|
+
if (name !== undefined)
|
|
549
|
+
lastUser = { line: instruction.line, name };
|
|
550
|
+
}
|
|
551
|
+
const root = !lastUser || lastUser.name.toLowerCase() === "root" || lastUser.name === "0";
|
|
552
|
+
resources.push({
|
|
553
|
+
id: "dockerfile.user",
|
|
554
|
+
type: "dockerfile:user",
|
|
555
|
+
value: { user: lastUser?.name ?? "", root },
|
|
556
|
+
locate: () => lineLocation(lastUser?.line ?? froms[0].line, "user"),
|
|
557
|
+
});
|
|
558
|
+
instructions.forEach((instruction, index) => {
|
|
559
|
+
if (!/^RUN\b/i.test(instruction.text))
|
|
560
|
+
return;
|
|
561
|
+
resources.push({
|
|
562
|
+
id: `dockerfile.run.${index + 1}`,
|
|
563
|
+
type: "dockerfile:run",
|
|
564
|
+
value: { piped: runPipesToShell(instruction.text) },
|
|
565
|
+
locate: () => lineLocation(instruction.line, "run"),
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
instructions.forEach((instruction, index) => {
|
|
569
|
+
const sources = copySources(instruction.text);
|
|
570
|
+
if (!sources)
|
|
571
|
+
return;
|
|
572
|
+
resources.push({
|
|
573
|
+
id: `dockerfile.copy.${index + 1}`,
|
|
574
|
+
type: "dockerfile:copy",
|
|
575
|
+
value: { secret: sources.some(sourceIsSecret) },
|
|
576
|
+
locate: () => lineLocation(instruction.line, "copy"),
|
|
577
|
+
});
|
|
578
|
+
});
|
|
579
|
+
return {
|
|
580
|
+
format: "dockerfile",
|
|
581
|
+
resources,
|
|
582
|
+
warnings: [submittedTextWarning],
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
function triggersPullRequestTarget(value) {
|
|
586
|
+
if (value === "pull_request_target")
|
|
587
|
+
return true;
|
|
588
|
+
if (Array.isArray(value))
|
|
589
|
+
return value.some((item) => item === "pull_request_target");
|
|
590
|
+
return Object.prototype.hasOwnProperty.call(object(value), "pull_request_target");
|
|
591
|
+
}
|
|
592
|
+
function workflowChecksOutHead(jobs) {
|
|
593
|
+
for (const job of Object.values(object(jobs))) {
|
|
594
|
+
for (const step of array(object(job).steps)) {
|
|
595
|
+
const inputs = object(object(step).with);
|
|
596
|
+
const text = [inputs.ref, inputs.sha]
|
|
597
|
+
.filter((item) => typeof item === "string")
|
|
598
|
+
.join("\n");
|
|
599
|
+
if (text.includes("github.event.pull_request.head"))
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
function runPrintsSecret(script) {
|
|
606
|
+
const logical = script.replace(/\\\r?\n\s*/g, " ");
|
|
607
|
+
return logical.split(/\n/).some((line) => {
|
|
608
|
+
const code = line.replace(/(^|\s)#[^\n]*$/, "");
|
|
609
|
+
return (/\b(?:echo|printf|print)\b/i.test(code) &&
|
|
610
|
+
/\$\{\{\s*secrets\./.test(code));
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
function permissionsAreBroad(top, jobs, pullRequestTarget) {
|
|
614
|
+
const values = [
|
|
615
|
+
top,
|
|
616
|
+
...Object.values(object(jobs)).map((job) => object(job).permissions),
|
|
617
|
+
];
|
|
618
|
+
if (values.some((value) => value === "write-all"))
|
|
619
|
+
return true;
|
|
620
|
+
if (!pullRequestTarget)
|
|
621
|
+
return false;
|
|
622
|
+
return values.some((value) => object(value).contents === "write");
|
|
623
|
+
}
|
|
624
|
+
function parseGithubWorkflow(content) {
|
|
625
|
+
const lines = new LineCounter();
|
|
626
|
+
let documents;
|
|
627
|
+
try {
|
|
628
|
+
documents = parseAllDocuments(content, {
|
|
629
|
+
lineCounter: lines,
|
|
630
|
+
prettyErrors: false,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
throw new ScanInputError("Invalid GitHub Actions workflow YAML.");
|
|
635
|
+
}
|
|
636
|
+
const resources = [];
|
|
637
|
+
let found = false;
|
|
638
|
+
for (const [documentIndex, doc] of documents.entries()) {
|
|
639
|
+
if (doc.errors.length)
|
|
640
|
+
throw new ScanInputError("Invalid GitHub Actions workflow YAML.");
|
|
641
|
+
let data;
|
|
642
|
+
try {
|
|
643
|
+
data = doc.toJS({ maxAliasCount: 0 });
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
|
|
647
|
+
}
|
|
648
|
+
if (data == null)
|
|
649
|
+
continue;
|
|
650
|
+
checkShape(data);
|
|
651
|
+
const root = object(data);
|
|
652
|
+
if (!("on" in root) || !("jobs" in root))
|
|
653
|
+
continue;
|
|
654
|
+
found = true;
|
|
655
|
+
const pullRequestTarget = triggersPullRequestTarget(root.on);
|
|
656
|
+
const checksOutHead = workflowChecksOutHead(root.jobs);
|
|
657
|
+
const broad = permissionsAreBroad(root.permissions, root.jobs, pullRequestTarget);
|
|
658
|
+
const position = (path) => {
|
|
659
|
+
let node = doc.getIn(path, true);
|
|
660
|
+
if (!node?.range)
|
|
661
|
+
node = doc.getIn(["on"], true);
|
|
662
|
+
const at = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
|
|
663
|
+
return {
|
|
664
|
+
line: at.line,
|
|
665
|
+
column: at.col,
|
|
666
|
+
path: [documentIndex, ...path].join("."),
|
|
667
|
+
};
|
|
668
|
+
};
|
|
669
|
+
resources.push({
|
|
670
|
+
id: "workflow.on",
|
|
671
|
+
type: "github:trigger",
|
|
672
|
+
value: { pullRequestTarget, checksOutHead },
|
|
673
|
+
locate: () => position(["on"]),
|
|
674
|
+
});
|
|
675
|
+
resources.push({
|
|
676
|
+
id: "workflow.permissions",
|
|
677
|
+
type: "github:permissions",
|
|
678
|
+
value: { broad },
|
|
679
|
+
locate: () => position(root.permissions != null ? ["permissions"] : ["on"]),
|
|
680
|
+
});
|
|
681
|
+
for (const [jobName, job] of Object.entries(object(root.jobs))) {
|
|
682
|
+
array(object(job).steps).forEach((step, index) => {
|
|
683
|
+
const uses = object(step).uses;
|
|
684
|
+
if (typeof uses !== "string")
|
|
685
|
+
return;
|
|
686
|
+
const ref = uses.includes("@")
|
|
687
|
+
? uses.slice(uses.lastIndexOf("@") + 1)
|
|
688
|
+
: "";
|
|
689
|
+
resources.push({
|
|
690
|
+
id: `jobs.${jobName}.steps.${index}`,
|
|
691
|
+
type: "github:action",
|
|
692
|
+
value: { uses, floating: ref === "main" || ref === "master" },
|
|
693
|
+
locate: () => position(["jobs", jobName, "steps", index, "uses"]),
|
|
694
|
+
});
|
|
695
|
+
});
|
|
696
|
+
array(object(job).steps).forEach((step, index) => {
|
|
697
|
+
const run = object(step).run;
|
|
698
|
+
if (typeof run !== "string")
|
|
699
|
+
return;
|
|
700
|
+
resources.push({
|
|
701
|
+
id: `jobs.${jobName}.steps.${index}.run`,
|
|
702
|
+
type: "github:run",
|
|
703
|
+
value: { printsSecret: runPrintsSecret(run) },
|
|
704
|
+
locate: () => position(["jobs", jobName, "steps", index, "run"]),
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (!found)
|
|
710
|
+
throw new ScanInputError("A GitHub Actions workflow requires top-level on and jobs keys.");
|
|
711
|
+
return {
|
|
712
|
+
format: "github",
|
|
713
|
+
resources,
|
|
714
|
+
warnings: [submittedTextWarning],
|
|
715
|
+
};
|
|
716
|
+
}
|
|
355
717
|
function composeServiceMap(root) {
|
|
356
718
|
if (root.apiVersion || root.kind || root.Resources || root.resources)
|
|
357
719
|
return undefined;
|
|
@@ -382,3 +744,95 @@ function pulumiResourceMap(root) {
|
|
|
382
744
|
function escapeRegex(value) {
|
|
383
745
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
384
746
|
}
|
|
747
|
+
function maskNonCode(content) {
|
|
748
|
+
const withoutBlockComments = content
|
|
749
|
+
.replace(/\{\{-?\s*\/\*[\s\S]*?\*\/\s*-?\}\}/g, (match) => match.replace(/[^\n]/g, " "))
|
|
750
|
+
.replace(/\{#[\s\S]*?#\}/g, (match) => match.replace(/[^\n]/g, " "));
|
|
751
|
+
return withoutBlockComments
|
|
752
|
+
.split("\n")
|
|
753
|
+
.map((line) => {
|
|
754
|
+
let out = "";
|
|
755
|
+
let templates = 0;
|
|
756
|
+
let quote;
|
|
757
|
+
for (let index = 0; index < line.length; index++) {
|
|
758
|
+
const char = line[index];
|
|
759
|
+
const next = line[index + 1];
|
|
760
|
+
if (quote) {
|
|
761
|
+
out += char;
|
|
762
|
+
if (char === quote && line[index - 1] !== "\\")
|
|
763
|
+
quote = undefined;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (templates === 0 && (char === '"' || char === "'")) {
|
|
767
|
+
quote = char;
|
|
768
|
+
out += char;
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (char === "{" && next === "{") {
|
|
772
|
+
templates += 1;
|
|
773
|
+
out += char;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (char === "}" && next === "}" && templates > 0) {
|
|
777
|
+
templates -= 1;
|
|
778
|
+
out += char;
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (templates === 0 && char === "#") {
|
|
782
|
+
return out + " ".repeat(line.length - out.length);
|
|
783
|
+
}
|
|
784
|
+
out += char;
|
|
785
|
+
}
|
|
786
|
+
return out;
|
|
787
|
+
})
|
|
788
|
+
.join("\n");
|
|
789
|
+
}
|
|
790
|
+
function lineAt(content, index) {
|
|
791
|
+
return content.slice(0, index).split("\n").length;
|
|
792
|
+
}
|
|
793
|
+
function firstLiteral(content, pattern) {
|
|
794
|
+
const match = pattern.exec(content);
|
|
795
|
+
if (!match || match.index === undefined)
|
|
796
|
+
return { present: false, line: 1 };
|
|
797
|
+
return { present: true, line: lineAt(content, match.index) };
|
|
798
|
+
}
|
|
799
|
+
function literalResource(id, type, match, path) {
|
|
800
|
+
return {
|
|
801
|
+
id,
|
|
802
|
+
type,
|
|
803
|
+
value: { present: match.present },
|
|
804
|
+
locate: () => lineLocation(match.line, path),
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const helmTemplateWarning = "Helm templates are not executed. Only literal text outside comments is checked.";
|
|
808
|
+
const ansibleFactsWarning = "Ansible facts are not executed. Only literal text outside comments is checked.";
|
|
809
|
+
function parseHelm(content) {
|
|
810
|
+
const visible = maskNonCode(content);
|
|
811
|
+
const privileged = firstLiteral(visible, /privileged\s*:\s*["']?true["']?\b/);
|
|
812
|
+
const hostNetwork = firstLiteral(visible, /hostNetwork\s*:\s*["']?true["']?\b/);
|
|
813
|
+
const latest = firstLiteral(visible, /:latest\b/);
|
|
814
|
+
const socket = firstLiteral(visible, /(?:hostPath|volumes?)\b[\s\S]{0,500}docker\.sock|docker\.sock[\s\S]{0,200}\b(?:hostPath|volumes?)\b/);
|
|
815
|
+
return {
|
|
816
|
+
format: "helm",
|
|
817
|
+
resources: [
|
|
818
|
+
literalResource("helm.privileged", "helm:privileged", privileged, "privileged"),
|
|
819
|
+
literalResource("helm.hostNetwork", "helm:hostNetwork", hostNetwork, "hostNetwork"),
|
|
820
|
+
literalResource("helm.image", "helm:image", latest, "image"),
|
|
821
|
+
literalResource("helm.socket", "helm:socket", socket, "volume"),
|
|
822
|
+
],
|
|
823
|
+
warnings: [helmTemplateWarning],
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
function parseAnsible(content) {
|
|
827
|
+
const visible = maskNonCode(content);
|
|
828
|
+
const become = firstLiteral(visible, /(?:^|\n)\s*(?:-\s+)?become\s*:\s*["']?(?:true|yes)["']?(?=\s|$)/);
|
|
829
|
+
const cidr = firstLiteral(visible, /(?:cidr|source)\s*:\s*(?:\|\s*)?(?:\n\s*-\s*)?["']?0\.0\.0\.0\/0\b/);
|
|
830
|
+
return {
|
|
831
|
+
format: "ansible",
|
|
832
|
+
resources: [
|
|
833
|
+
literalResource("ansible.become", "ansible:become", become, "become"),
|
|
834
|
+
literalResource("ansible.cidr", "ansible:cidr", cidr, "cidr"),
|
|
835
|
+
],
|
|
836
|
+
warnings: [ansibleFactsWarning],
|
|
837
|
+
};
|
|
838
|
+
}
|
package/dist/iac/rules.js
CHANGED
|
@@ -197,6 +197,104 @@ export const rules = {
|
|
|
197
197
|
detail: "A role grants wildcard verbs, resources, or API groups.",
|
|
198
198
|
remediation: "Scope the role to the required API groups, resource names, and verbs.",
|
|
199
199
|
},
|
|
200
|
+
DF001: {
|
|
201
|
+
category: "security",
|
|
202
|
+
severity: "high",
|
|
203
|
+
title: "Dockerfile runs as root",
|
|
204
|
+
detail: "The last USER is root or 0, or the Dockerfile has no USER instruction.",
|
|
205
|
+
remediation: "Add a final USER instruction for a non-root account that the image supports.",
|
|
206
|
+
},
|
|
207
|
+
DF002: {
|
|
208
|
+
category: "security",
|
|
209
|
+
severity: "medium",
|
|
210
|
+
title: "Unpinned container image",
|
|
211
|
+
detail: "A FROM image uses the latest tag, or it has no tag and no digest.",
|
|
212
|
+
remediation: "Pin a release tag or an image digest. Scratch and named stages are not image pins.",
|
|
213
|
+
},
|
|
214
|
+
DF003: {
|
|
215
|
+
category: "security",
|
|
216
|
+
severity: "high",
|
|
217
|
+
title: "Remote script piped to a shell",
|
|
218
|
+
detail: "A RUN instruction downloads a script with curl or wget and pipes it to sh or bash.",
|
|
219
|
+
remediation: "Download the script, review it, and pin a checksum before running it. Do not pipe a remote script to a shell.",
|
|
220
|
+
},
|
|
221
|
+
GH001: {
|
|
222
|
+
category: "security",
|
|
223
|
+
severity: "critical",
|
|
224
|
+
title: "Pull request target checks out PR code",
|
|
225
|
+
detail: "The workflow triggers on pull_request_target and checks out the pull request head ref or head sha.",
|
|
226
|
+
remediation: "Avoid checking out pull request head code from pull_request_target. Use pull_request for untrusted code, or check out the base repository only.",
|
|
227
|
+
},
|
|
228
|
+
GH002: {
|
|
229
|
+
category: "security",
|
|
230
|
+
severity: "high",
|
|
231
|
+
title: "Workflow permissions are broad",
|
|
232
|
+
detail: "permissions is write-all, or a pull_request_target workflow sets contents to write.",
|
|
233
|
+
remediation: "Set the narrowest permissions the workflow needs. Keep contents read-only on pull_request_target.",
|
|
234
|
+
},
|
|
235
|
+
GH003: {
|
|
236
|
+
category: "security",
|
|
237
|
+
severity: "medium",
|
|
238
|
+
title: "Action ref is a floating branch",
|
|
239
|
+
detail: "A uses value is pinned to @main or @master.",
|
|
240
|
+
remediation: "Pin the action to a release tag or a full commit SHA.",
|
|
241
|
+
},
|
|
242
|
+
DF004: {
|
|
243
|
+
category: "security",
|
|
244
|
+
severity: "high",
|
|
245
|
+
title: "Secret file copied into the image",
|
|
246
|
+
detail: "A COPY or ADD instruction uses a secret file as a source.",
|
|
247
|
+
remediation: "Keep secret files out of the image. Mount them at runtime from a secret store.",
|
|
248
|
+
},
|
|
249
|
+
GH004: {
|
|
250
|
+
category: "security",
|
|
251
|
+
severity: "high",
|
|
252
|
+
title: "Workflow prints a secret",
|
|
253
|
+
detail: "A run script echoes or prints a secrets expression.",
|
|
254
|
+
remediation: "Remove the printed secret from the script. Pass secrets through an environment variable that the step does not print.",
|
|
255
|
+
},
|
|
256
|
+
HELM001: {
|
|
257
|
+
category: "security",
|
|
258
|
+
severity: "critical",
|
|
259
|
+
title: "Privileged container in a Helm template",
|
|
260
|
+
detail: "The template sets privileged to true.",
|
|
261
|
+
remediation: "Set privileged to false. Isolate workloads that genuinely require elevated privileges.",
|
|
262
|
+
},
|
|
263
|
+
HELM002: {
|
|
264
|
+
category: "security",
|
|
265
|
+
severity: "high",
|
|
266
|
+
title: "Host network in a Helm template",
|
|
267
|
+
detail: "The template sets hostNetwork to true.",
|
|
268
|
+
remediation: "Disable hostNetwork unless a reviewed node-level component requires it.",
|
|
269
|
+
},
|
|
270
|
+
HELM003: {
|
|
271
|
+
category: "reliability",
|
|
272
|
+
severity: "medium",
|
|
273
|
+
title: "Unpinned image in a Helm template",
|
|
274
|
+
detail: "An image tag uses latest.",
|
|
275
|
+
remediation: "Pin an immutable digest or a release tag.",
|
|
276
|
+
},
|
|
277
|
+
HELM004: {
|
|
278
|
+
category: "security",
|
|
279
|
+
severity: "high",
|
|
280
|
+
title: "Docker socket mounted in a Helm template",
|
|
281
|
+
detail: "A hostPath or volume contains docker.sock.",
|
|
282
|
+
remediation: "Remove the Docker socket mount. Use a scoped volume or a reviewed runtime API instead.",
|
|
283
|
+
},
|
|
284
|
+
ANS001: {
|
|
285
|
+
category: "security",
|
|
286
|
+
severity: "high",
|
|
287
|
+
title: "Ansible task runs as root",
|
|
288
|
+
detail: "A play or task sets become to true or yes.",
|
|
289
|
+
remediation: "Set become to false unless the task has a reviewed need to run as root.",
|
|
290
|
+
},
|
|
291
|
+
ANS002: {
|
|
292
|
+
category: "security",
|
|
293
|
+
severity: "high",
|
|
294
|
+
title: "Broad network exposure",
|
|
295
|
+
detail: "A cidr or source allows 0.0.0.0/0.",
|
|
296
|
+
remediation: "Restrict the cidr or source to approved addresses.",
|
|
297
|
+
},
|
|
200
298
|
};
|
|
201
299
|
export function resourceFindings(resources, format) {
|
|
202
300
|
const findings = [];
|
|
@@ -212,6 +310,22 @@ export function resourceFindings(resources, format) {
|
|
|
212
310
|
location: resource.locate(path),
|
|
213
311
|
});
|
|
214
312
|
};
|
|
313
|
+
if (format === "dockerfile") {
|
|
314
|
+
checkDockerfile(resource, check);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (format === "github") {
|
|
318
|
+
checkGithub(resource, check);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
if (format === "helm") {
|
|
322
|
+
checkHelm(resource, check);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (format === "ansible") {
|
|
326
|
+
checkAnsible(resource, check);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
215
329
|
walk(resource.value, (key, value, path, parent) => {
|
|
216
330
|
if (typeof value !== "string")
|
|
217
331
|
return;
|
|
@@ -232,6 +346,43 @@ export function resourceFindings(resources, format) {
|
|
|
232
346
|
}
|
|
233
347
|
return { findings, checked };
|
|
234
348
|
}
|
|
349
|
+
function checkDockerfile(resource, check) {
|
|
350
|
+
if (resource.type === "dockerfile:user")
|
|
351
|
+
check("DF001", resource.value.root === true, ["user"]);
|
|
352
|
+
else if (resource.type === "dockerfile:from")
|
|
353
|
+
check("DF002", resource.value.unpinned === true, ["image"]);
|
|
354
|
+
else if (resource.type === "dockerfile:run")
|
|
355
|
+
check("DF003", resource.value.piped === true, ["run"]);
|
|
356
|
+
else if (resource.type === "dockerfile:copy")
|
|
357
|
+
check("DF004", resource.value.secret === true, ["copy"]);
|
|
358
|
+
}
|
|
359
|
+
function checkGithub(resource, check) {
|
|
360
|
+
if (resource.type === "github:trigger")
|
|
361
|
+
check("GH001", resource.value.pullRequestTarget === true &&
|
|
362
|
+
resource.value.checksOutHead === true, ["on"]);
|
|
363
|
+
else if (resource.type === "github:permissions")
|
|
364
|
+
check("GH002", resource.value.broad === true, ["permissions"]);
|
|
365
|
+
else if (resource.type === "github:action")
|
|
366
|
+
check("GH003", resource.value.floating === true, ["uses"]);
|
|
367
|
+
else if (resource.type === "github:run")
|
|
368
|
+
check("GH004", resource.value.printsSecret === true, ["run"]);
|
|
369
|
+
}
|
|
370
|
+
function checkHelm(resource, check) {
|
|
371
|
+
if (resource.type === "helm:privileged")
|
|
372
|
+
check("HELM001", resource.value.present === true, ["privileged"]);
|
|
373
|
+
else if (resource.type === "helm:hostNetwork")
|
|
374
|
+
check("HELM002", resource.value.present === true, ["hostNetwork"]);
|
|
375
|
+
else if (resource.type === "helm:image")
|
|
376
|
+
check("HELM003", resource.value.present === true, ["image"]);
|
|
377
|
+
else if (resource.type === "helm:socket")
|
|
378
|
+
check("HELM004", resource.value.present === true, ["volume"]);
|
|
379
|
+
}
|
|
380
|
+
function checkAnsible(resource, check) {
|
|
381
|
+
if (resource.type === "ansible:become")
|
|
382
|
+
check("ANS001", resource.value.present === true, ["become"]);
|
|
383
|
+
else if (resource.type === "ansible:cidr")
|
|
384
|
+
check("ANS002", resource.value.present === true, ["cidr"]);
|
|
385
|
+
}
|
|
235
386
|
function walk(value, visit, path = []) {
|
|
236
387
|
if (Array.isArray(value))
|
|
237
388
|
value.forEach((child, index) => walk(child, visit, [...path, index]));
|
package/dist/server.js
CHANGED
|
@@ -73,12 +73,16 @@ export function createServer(remote = false) {
|
|
|
73
73
|
"kubernetes",
|
|
74
74
|
"pulumi",
|
|
75
75
|
"compose",
|
|
76
|
+
"dockerfile",
|
|
77
|
+
"github",
|
|
78
|
+
"helm",
|
|
79
|
+
"ansible",
|
|
76
80
|
"application",
|
|
77
81
|
"auto",
|
|
78
82
|
])
|
|
79
83
|
.optional()
|
|
80
84
|
.default("auto")
|
|
81
|
-
.describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, application, auto"),
|
|
85
|
+
.describe("Content format: terraform, cloudformation, kubernetes, pulumi, compose, dockerfile, github, helm, ansible, application, auto."),
|
|
82
86
|
},
|
|
83
87
|
}, async ({ content, focus, format, minSeverity, maxFindings }) => {
|
|
84
88
|
const botHints = fetchPublicBotHints();
|
|
@@ -126,7 +130,7 @@ export function createServer(remote = false) {
|
|
|
126
130
|
description: (name === "scan_iac_stub"
|
|
127
131
|
? "Compatibility alias for scan_iac; runs the full scanner. "
|
|
128
132
|
: "") +
|
|
129
|
-
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
|
|
133
|
+
"Parse Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes manifests, Pulumi YAML, Pulumi programs, or Docker Compose. Includes Dockerfile, GitHub Actions, Helm templates, and Ansible playbooks. Helm templates and Ansible facts are not executed. Check security, reliability and cost rules with resource locations and remediation. Pulumi programs are scanned as text.",
|
|
130
134
|
annotations: {
|
|
131
135
|
readOnlyHint: true,
|
|
132
136
|
destructiveHint: false,
|
|
@@ -134,7 +138,7 @@ export function createServer(remote = false) {
|
|
|
134
138
|
},
|
|
135
139
|
_meta: { securitySchemes: [{ type: "oauth2", scopes: ["mcp"] }] },
|
|
136
140
|
inputSchema: {
|
|
137
|
-
content: contentSchema.describe("Terraform HCL/JSON, CloudFormation YAML/JSON, Kubernetes YAML/JSON, Pulumi YAML or program text, or Docker Compose"),
|
|
141
|
+
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, and Ansible playbooks."),
|
|
138
142
|
format: z
|
|
139
143
|
.enum([
|
|
140
144
|
"terraform",
|
|
@@ -142,6 +146,10 @@ export function createServer(remote = false) {
|
|
|
142
146
|
"kubernetes",
|
|
143
147
|
"pulumi",
|
|
144
148
|
"compose",
|
|
149
|
+
"dockerfile",
|
|
150
|
+
"github",
|
|
151
|
+
"helm",
|
|
152
|
+
"ansible",
|
|
145
153
|
"auto",
|
|
146
154
|
])
|
|
147
155
|
.default("auto"),
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.6.
|
|
1
|
+
export declare const VERSION = "0.6.10";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.6.
|
|
1
|
+
export const VERSION = "0.6.10";
|