@skaleagents/swarm 0.4.0 → 0.6.0
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/README.md +95 -3
- package/dist/application/graph.d.ts +45 -0
- package/dist/application/graph.js +264 -0
- package/dist/application/intake.d.ts +63 -0
- package/dist/application/intake.js +182 -0
- package/dist/application/review.d.ts +71 -0
- package/dist/application/review.js +360 -0
- package/dist/application/tools.d.ts +2 -0
- package/dist/application/tools.js +94 -0
- package/dist/iac/parse.d.ts +25 -0
- package/dist/iac/parse.js +239 -0
- package/dist/iac/rules.d.ts +16 -0
- package/dist/iac/rules.js +450 -0
- package/dist/iac/scan.d.ts +43 -0
- package/dist/iac/scan.js +60 -0
- package/dist/review.d.ts +11 -1
- package/dist/review.js +72 -11
- package/dist/server.js +118 -68
- package/dist/version.d.ts +1 -0
- package/dist/version.js +1 -0
- package/package.json +6 -4
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import hcl from "hcl2-parser";
|
|
2
|
+
import { LineCounter, parseAllDocuments } from "yaml";
|
|
3
|
+
export class ScanInputError extends Error {
|
|
4
|
+
constructor(message) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "ScanInputError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function object(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
11
|
+
? value
|
|
12
|
+
: {};
|
|
13
|
+
}
|
|
14
|
+
export function array(value) {
|
|
15
|
+
return Array.isArray(value) ? value : value == null ? [] : [value];
|
|
16
|
+
}
|
|
17
|
+
// Reject deeply nested documents before rule traversal. Never return source text in errors.
|
|
18
|
+
function checkShape(value, depth = 0, budget = { remaining: 50_000 }) {
|
|
19
|
+
if (depth > 80 || --budget.remaining < 0) {
|
|
20
|
+
throw new ScanInputError("Document exceeds the nesting or node limit. Split it into smaller inputs.");
|
|
21
|
+
}
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
for (const child of Object.values(value))
|
|
24
|
+
checkShape(child, depth + 1, budget);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function looksLikeIac(content) {
|
|
28
|
+
if (content.trimStart().startsWith("{")) {
|
|
29
|
+
try {
|
|
30
|
+
const value = object(JSON.parse(content));
|
|
31
|
+
if (value.resource || value.Resources || (value.apiVersion && value.kind))
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* Other format detection still applies to incomplete input. */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return (/^\s*(?:resource|module|terraform|variable|provider)\s+["{]/m.test(content) ||
|
|
39
|
+
/^\s*(?:["']?Resources["']?\s*:|apiVersion\s*:)/m.test(content) ||
|
|
40
|
+
/^\s*\{\s*"(?:resource|Resources|apiVersion|AWSTemplateFormatVersion)"\s*:/.test(content));
|
|
41
|
+
}
|
|
42
|
+
export function parseIac(content, requested = "auto") {
|
|
43
|
+
if (!content.trim())
|
|
44
|
+
throw new ScanInputError("Content must not be empty or whitespace.");
|
|
45
|
+
const warnings = [];
|
|
46
|
+
const resources = [];
|
|
47
|
+
const isHcl = (requested === "terraform" && !content.trimStart().startsWith("{")) ||
|
|
48
|
+
(requested === "auto" &&
|
|
49
|
+
/^\s*(?:(?:resource|module|variable|provider|data|output)\s+"|(?:terraform|locals)\s*\{)/m.test(content));
|
|
50
|
+
if (isHcl) {
|
|
51
|
+
let data;
|
|
52
|
+
try {
|
|
53
|
+
const [parsed, error] = hcl.parseToObject(content);
|
|
54
|
+
if (error || !parsed)
|
|
55
|
+
throw new Error("parse");
|
|
56
|
+
data = parsed;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
throw new ScanInputError("Invalid Terraform HCL. Check block syntax and attribute separators.");
|
|
60
|
+
}
|
|
61
|
+
checkShape(data);
|
|
62
|
+
// The HCL parser preserves expressions but does not expose source ranges.
|
|
63
|
+
// Report the resource declaration line and the exact parsed property path.
|
|
64
|
+
const searchable = content.replace(/\/\*[\s\S]*?\*\/|(?:#|\/\/)[^\n]*/g, (match) => match.replace(/[^\n]/g, " "));
|
|
65
|
+
for (const [type, instances] of Object.entries(object(object(data).resource))) {
|
|
66
|
+
for (const [name, blocks] of Object.entries(object(instances))) {
|
|
67
|
+
const declaration = new RegExp(`\\bresource\\s+"${escapeRegex(type)}"\\s+"${escapeRegex(name)}"`).exec(searchable);
|
|
68
|
+
const offset = declaration?.index ?? 0;
|
|
69
|
+
const before = content.slice(0, offset);
|
|
70
|
+
resources.push({
|
|
71
|
+
id: `${type}.${name}`,
|
|
72
|
+
type,
|
|
73
|
+
value: object(array(blocks)[0]),
|
|
74
|
+
locate: (path = []) => ({
|
|
75
|
+
line: before.split("\n").length,
|
|
76
|
+
column: offset - before.lastIndexOf("\n"),
|
|
77
|
+
path: ["resource", type, name, ...path].join("."),
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (object(data).module)
|
|
83
|
+
warnings.push("External Terraform modules are not expanded. Scan their source separately.");
|
|
84
|
+
if (JSON.stringify(data).includes("${"))
|
|
85
|
+
warnings.push("Terraform expressions are not evaluated. Findings use literal values and declared settings.");
|
|
86
|
+
if (!resources.length)
|
|
87
|
+
warnings.push("No resource declarations found. Variables, data sources, and outputs are not scanned as resources.");
|
|
88
|
+
return { format: "terraform", resources, warnings };
|
|
89
|
+
}
|
|
90
|
+
const lines = new LineCounter();
|
|
91
|
+
const tags = [
|
|
92
|
+
"Ref",
|
|
93
|
+
"Sub",
|
|
94
|
+
"GetAtt",
|
|
95
|
+
"Join",
|
|
96
|
+
"Select",
|
|
97
|
+
"Split",
|
|
98
|
+
"If",
|
|
99
|
+
"Equals",
|
|
100
|
+
"Not",
|
|
101
|
+
"And",
|
|
102
|
+
"Or",
|
|
103
|
+
"FindInMap",
|
|
104
|
+
"ImportValue",
|
|
105
|
+
"GetAZs",
|
|
106
|
+
"Base64",
|
|
107
|
+
"Cidr",
|
|
108
|
+
"Transform",
|
|
109
|
+
"Length",
|
|
110
|
+
"ToJsonString",
|
|
111
|
+
];
|
|
112
|
+
let documents;
|
|
113
|
+
try {
|
|
114
|
+
documents = parseAllDocuments(content, {
|
|
115
|
+
lineCounter: lines,
|
|
116
|
+
prettyErrors: false,
|
|
117
|
+
customTags: tags.flatMap((name) => ["scalar", "seq", "map"].map((kind) => ({
|
|
118
|
+
tag: `!${name}`,
|
|
119
|
+
...(kind === "scalar" ? {} : { collection: kind }),
|
|
120
|
+
resolve: () => ({ __intrinsic: name }),
|
|
121
|
+
}))),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw new ScanInputError("Invalid YAML or JSON document.");
|
|
126
|
+
}
|
|
127
|
+
let format = requested === "auto" ? undefined : requested;
|
|
128
|
+
for (const [documentIndex, doc] of documents.entries()) {
|
|
129
|
+
if (doc.errors.length || doc.warnings.length) {
|
|
130
|
+
const issue = doc.errors[0] ?? doc.warnings[0];
|
|
131
|
+
const line = lines.linePos(issue.pos[0]).line;
|
|
132
|
+
throw new ScanInputError(`Invalid or unsupported YAML/JSON syntax at line ${line}.`);
|
|
133
|
+
}
|
|
134
|
+
let data;
|
|
135
|
+
try {
|
|
136
|
+
data = doc.toJS({ maxAliasCount: 0 });
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new ScanInputError("YAML aliases are not supported. Expand anchors before scanning.");
|
|
140
|
+
}
|
|
141
|
+
if (data == null)
|
|
142
|
+
continue;
|
|
143
|
+
checkShape(data);
|
|
144
|
+
const root = object(data);
|
|
145
|
+
const detected = root.Resources
|
|
146
|
+
? "cloudformation"
|
|
147
|
+
: root.apiVersion && root.kind
|
|
148
|
+
? "kubernetes"
|
|
149
|
+
: root.resource
|
|
150
|
+
? "terraform"
|
|
151
|
+
: undefined;
|
|
152
|
+
format ??= detected;
|
|
153
|
+
if (!format || (detected && detected !== format))
|
|
154
|
+
throw new ScanInputError("Input format is unsupported or mixed. Submit one IaC format per scan.");
|
|
155
|
+
const add = (id, type, value, prefix) => {
|
|
156
|
+
resources.push({
|
|
157
|
+
id,
|
|
158
|
+
type,
|
|
159
|
+
value: object(value),
|
|
160
|
+
locate: (path = []) => {
|
|
161
|
+
let node = doc.getIn([...prefix, ...path], true);
|
|
162
|
+
if (!node?.range)
|
|
163
|
+
node = doc.getIn(prefix, true);
|
|
164
|
+
const position = lines.linePos(node?.range?.[0] ?? doc.range?.[0] ?? 0);
|
|
165
|
+
return {
|
|
166
|
+
line: position.line,
|
|
167
|
+
column: position.col,
|
|
168
|
+
path: [documentIndex, ...prefix, ...path].join("."),
|
|
169
|
+
};
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
};
|
|
173
|
+
if (format === "cloudformation") {
|
|
174
|
+
if (!root.Resources ||
|
|
175
|
+
Array.isArray(root.Resources) ||
|
|
176
|
+
typeof root.Resources !== "object")
|
|
177
|
+
throw new ScanInputError("CloudFormation requires a Resources mapping.");
|
|
178
|
+
for (const [name, raw] of Object.entries(object(root.Resources))) {
|
|
179
|
+
const value = object(raw);
|
|
180
|
+
if (typeof value.Type !== "string")
|
|
181
|
+
throw new ScanInputError("Each CloudFormation resource requires a Type.");
|
|
182
|
+
add(name, value.Type, value, ["Resources", name]);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (format === "terraform") {
|
|
186
|
+
if (!root.resource ||
|
|
187
|
+
Array.isArray(root.resource) ||
|
|
188
|
+
typeof root.resource !== "object")
|
|
189
|
+
throw new ScanInputError("Terraform JSON requires a resource mapping.");
|
|
190
|
+
for (const [type, instances] of Object.entries(object(root.resource))) {
|
|
191
|
+
for (const [name, value] of Object.entries(object(instances)))
|
|
192
|
+
add(`${type}.${name}`, type, value, ["resource", type, name]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
const manifests = root.kind === "List" ? array(root.items) : [root];
|
|
197
|
+
for (const [index, raw] of manifests.entries()) {
|
|
198
|
+
const value = object(raw);
|
|
199
|
+
if (typeof value.apiVersion !== "string" ||
|
|
200
|
+
typeof value.kind !== "string")
|
|
201
|
+
throw new ScanInputError("Each Kubernetes manifest requires apiVersion and kind.");
|
|
202
|
+
if ([
|
|
203
|
+
"Pod",
|
|
204
|
+
"Deployment",
|
|
205
|
+
"StatefulSet",
|
|
206
|
+
"DaemonSet",
|
|
207
|
+
"ReplicaSet",
|
|
208
|
+
"ReplicationController",
|
|
209
|
+
"Job",
|
|
210
|
+
"CronJob",
|
|
211
|
+
].includes(value.kind)) {
|
|
212
|
+
let spec = object(value.spec);
|
|
213
|
+
if (value.kind === "CronJob")
|
|
214
|
+
spec = object(object(spec.jobTemplate).spec);
|
|
215
|
+
if (value.kind !== "Pod")
|
|
216
|
+
spec = object(object(spec.template).spec);
|
|
217
|
+
if (!Array.isArray(spec.containers) ||
|
|
218
|
+
!spec.containers.length ||
|
|
219
|
+
spec.containers.some((c) => typeof object(c).name !== "string" ||
|
|
220
|
+
typeof object(c).image !== "string")) {
|
|
221
|
+
throw new ScanInputError("Kubernetes workloads require a containers list with a name and image for each container.");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const metadata = object(value.metadata);
|
|
225
|
+
add(`${value.kind}/${metadata.namespace ?? "default"}/${metadata.name ?? metadata.generateName ?? "unnamed"}`, value.kind, value, root.kind === "List" ? ["items", index] : []);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!format)
|
|
230
|
+
throw new ScanInputError("No IaC document found. Choose Terraform, CloudFormation, or Kubernetes.");
|
|
231
|
+
if (!resources.length)
|
|
232
|
+
warnings.push("No resources found in the submitted document.");
|
|
233
|
+
if (/(?:!(?:Ref|Sub|GetAtt|If)\b|"(?:Ref|Fn::\w+)"\s*:|\$\{)/.test(content))
|
|
234
|
+
warnings.push("Intrinsic functions and expressions are not evaluated. Only literal configuration is checked.");
|
|
235
|
+
return { format, resources, warnings };
|
|
236
|
+
}
|
|
237
|
+
function escapeRegex(value) {
|
|
238
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
239
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Resource } from "./parse.js";
|
|
2
|
+
import type { Finding, FindingSeverity } from "../review.js";
|
|
3
|
+
type Category = "security" | "reliability" | "cost";
|
|
4
|
+
type Rule = {
|
|
5
|
+
category: Category;
|
|
6
|
+
severity: FindingSeverity;
|
|
7
|
+
title: string;
|
|
8
|
+
detail: string;
|
|
9
|
+
remediation: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const rules: Record<string, Rule>;
|
|
12
|
+
export declare function resourceFindings(resources: Resource[], format: string): {
|
|
13
|
+
findings: Finding[];
|
|
14
|
+
checked: Set<string>;
|
|
15
|
+
};
|
|
16
|
+
export {};
|