@skaleagents/swarm 0.7.0 → 0.8.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 +6 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +74 -8
- package/dist/iac/scan.d.ts +4 -0
- package/dist/iac/scan.js +1 -1
- package/dist/secrets.d.ts +9 -0
- package/dist/secrets.js +101 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -21,6 +21,10 @@ the MCP tool for a structured review.
|
|
|
21
21
|
|
|
22
22
|
`npx -y @skaleagents/swarm scan [paths...]` scans IaC, CI, Dockerfile and package files on your machine. It needs no account and sends nothing over the network. Use `--changed` for files changed against HEAD, `--fail-on high` to exit 1, and `--json` for machine output.
|
|
23
23
|
|
|
24
|
+
Adopt it on an existing repo: `npx @skaleagents/swarm scan --write-baseline .skaleagents-baseline.json`, commit the file, and later scans show only new findings. The Action reads the baseline from the PR's base commit, so a PR cannot hide findings by editing it. Findings match on rule, file and resource, not line number.
|
|
25
|
+
|
|
26
|
+
`scan` also looks for committed secrets in any file it reads (AWS, GitHub, Slack, Stripe, Google, npm and Anthropic keys, private keys) as `SECRET001`, critical. The value is never printed. Git-ignored files such as a local `.env` are skipped. Rotate the key; only suppress a documented example.
|
|
27
|
+
|
|
24
28
|
Suppress an intended setting with a comment on the flagged line or the line above: `# skaleagents-ignore DATA001 public asset bucket`.
|
|
25
29
|
|
|
26
30
|
**Claude Code plugin.** It adds a stop hook that scans changed files and asks the agent to fix `high` and `critical` findings, plus a skill and the hosted MCP server:
|
|
@@ -33,7 +37,7 @@ Suppress an intended setting with a comment on the flagged line or the line abov
|
|
|
33
37
|
**Cursor.** Add `.cursor/hooks.json`:
|
|
34
38
|
|
|
35
39
|
```json
|
|
36
|
-
{ "version": 1, "hooks": { "stop": [{ "command": "npx -y --registry=https://registry.npmjs.org @skaleagents/swarm@0.
|
|
40
|
+
{ "version": 1, "hooks": { "stop": [{ "command": "npx -y --registry=https://registry.npmjs.org @skaleagents/swarm@0.8.0 scan --hook" }] } }
|
|
37
41
|
```
|
|
38
42
|
|
|
39
43
|
**GitHub Action.** It posts one PR comment and fails on `critical` by default:
|
|
@@ -47,7 +51,7 @@ jobs:
|
|
|
47
51
|
steps:
|
|
48
52
|
- uses: actions/checkout@v4
|
|
49
53
|
with: { fetch-depth: 0 }
|
|
50
|
-
- uses: SkaleAgents/mcp-server@v0.
|
|
54
|
+
- uses: SkaleAgents/mcp-server@v0.8.0
|
|
51
55
|
with: { fail-on: high }
|
|
52
56
|
```
|
|
53
57
|
|
package/dist/cli.d.ts
CHANGED
|
@@ -8,5 +8,7 @@ export declare function scanFiles(files: string[], minSeverity?: FindingSeverity
|
|
|
8
8
|
suppressed: number;
|
|
9
9
|
warnings: string[];
|
|
10
10
|
};
|
|
11
|
+
export declare const baselineKey: (f: FileFinding) => string;
|
|
12
|
+
export declare function readBaseline(path: string): Set<string>;
|
|
11
13
|
export declare function formatText(findings: FileFinding[], suppressed: number): string;
|
|
12
14
|
export declare function runScan(argv: string[]): Promise<number>;
|
package/dist/cli.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { basename, dirname, join, relative } from "node:path";
|
|
4
4
|
import { scanIac, severityRank } from "./iac/scan.js";
|
|
5
|
+
import { scanSecrets } from "./secrets.js";
|
|
5
6
|
const usage = `Usage: skaleagents-swarm scan [paths...] [options]
|
|
6
7
|
|
|
7
|
-
Scans IaC, CI and package files locally. No account or network needed.
|
|
8
|
+
Scans IaC, CI and package files, and any file for committed secrets, locally. No account or network needed.
|
|
8
9
|
|
|
9
10
|
--changed Scan files changed against HEAD plus untracked files
|
|
10
11
|
--min-severity <s> Hide findings below info|low|medium|high|critical (default low)
|
|
11
12
|
--fail-on <s> Exit 1 when a finding is at or above this severity
|
|
13
|
+
--baseline <file> Hide findings already listed in this baseline file (.skaleagents-baseline.json is used automatically)
|
|
14
|
+
--write-baseline <file> Write current findings to a baseline file and exit 0
|
|
12
15
|
--json Print JSON instead of text
|
|
13
16
|
--hook Agent stop hook mode for Claude Code and Cursor (implies --changed)
|
|
14
17
|
|
|
@@ -17,7 +20,7 @@ Suppress a finding with a comment on its line or the line above:
|
|
|
17
20
|
const candidate = /\.(tf|tfvars|hcl|ya?ml|json|bicep)$|(^|[/\\])(Dockerfile|Containerfile)[^/\\]*$/;
|
|
18
21
|
const skippedDirs = new Set([".git", "node_modules", "vendor", "dist", "build", ".terraform", ".next"]);
|
|
19
22
|
const skippedFiles = /(^|[/\\])(package-lock\.json|tsconfig[^/\\]*\.json|composer\.lock|pnpm-lock\.yaml|yarn\.lock)$/;
|
|
20
|
-
const maxFiles =
|
|
23
|
+
const maxFiles = 20000;
|
|
21
24
|
const severities = Object.keys(severityRank);
|
|
22
25
|
function walk(path, out) {
|
|
23
26
|
if (out.length >= maxFiles)
|
|
@@ -30,8 +33,7 @@ function walk(path, out) {
|
|
|
30
33
|
return;
|
|
31
34
|
}
|
|
32
35
|
if (stat.isFile()) {
|
|
33
|
-
|
|
34
|
-
out.push(path);
|
|
36
|
+
out.push(path);
|
|
35
37
|
return;
|
|
36
38
|
}
|
|
37
39
|
if (!stat.isDirectory())
|
|
@@ -46,6 +48,29 @@ function git(args) {
|
|
|
46
48
|
.split("\0")
|
|
47
49
|
.filter(Boolean);
|
|
48
50
|
}
|
|
51
|
+
// In a git repo, list tracked and untracked-but-not-ignored files so .gitignore keeps build output out of the count.
|
|
52
|
+
function listFiles(paths, warnings) {
|
|
53
|
+
let files = [];
|
|
54
|
+
try {
|
|
55
|
+
files = git(["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...(paths.length ? paths : ["."])]).filter((file) => {
|
|
56
|
+
try {
|
|
57
|
+
return lstatSync(file).isFile();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false; // Deleted in the working tree.
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
for (const path of paths.length ? paths : ["."])
|
|
66
|
+
walk(path, files);
|
|
67
|
+
}
|
|
68
|
+
if (files.length > maxFiles) {
|
|
69
|
+
warnings.push(`file limit reached: ${files.length - maxFiles} files not scanned`);
|
|
70
|
+
files = files.slice(0, maxFiles);
|
|
71
|
+
}
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
49
74
|
export function changedFiles() {
|
|
50
75
|
let tracked = [];
|
|
51
76
|
try {
|
|
@@ -91,6 +116,16 @@ export function scanFiles(files, minSeverity = "low") {
|
|
|
91
116
|
}
|
|
92
117
|
// Names come from scanned files, so they are flattened and capped before reaching an agent or PR comment.
|
|
93
118
|
const clean = (value, max = 160) => (value ?? "").replace(/[\x00-\x1f\x7f`]+/g, " ").slice(0, max);
|
|
119
|
+
// Line numbers are left out so unrelated edits above a finding do not make it look new.
|
|
120
|
+
export const baselineKey = (f) => `${f.ruleId}|${f.file.replaceAll("\\", "/")}|${f.resource ?? ""}`;
|
|
121
|
+
export function readBaseline(path) {
|
|
122
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
123
|
+
const keys = parsed?.findings;
|
|
124
|
+
if (!Array.isArray(keys) || keys.some((key) => typeof key !== "string")) {
|
|
125
|
+
throw new Error(`${path} is not a SkaleAgents baseline file`);
|
|
126
|
+
}
|
|
127
|
+
return new Set(keys);
|
|
128
|
+
}
|
|
94
129
|
export function formatText(findings, suppressed) {
|
|
95
130
|
if (findings.length === 0)
|
|
96
131
|
return `SkaleAgents: no findings${suppressed ? ` (${suppressed} suppressed)` : ""}.`;
|
|
@@ -110,6 +145,8 @@ export async function runScan(argv) {
|
|
|
110
145
|
let hook = false;
|
|
111
146
|
let minSeverity = "low";
|
|
112
147
|
let failOn;
|
|
148
|
+
let baselinePath;
|
|
149
|
+
let writeBaseline;
|
|
113
150
|
for (let i = 0; i < argv.length; i++) {
|
|
114
151
|
const arg = argv[i];
|
|
115
152
|
if (arg === "--") {
|
|
@@ -124,6 +161,15 @@ export async function runScan(argv) {
|
|
|
124
161
|
hook = changed = true;
|
|
125
162
|
else if (arg === "--min-severity")
|
|
126
163
|
minSeverity = severityArg(argv[++i], arg);
|
|
164
|
+
else if (arg === "--baseline" || arg === "--write-baseline") {
|
|
165
|
+
const value = argv[++i];
|
|
166
|
+
if (!value)
|
|
167
|
+
throw new Error(`${arg} needs a file path`);
|
|
168
|
+
if (arg === "--baseline")
|
|
169
|
+
baselinePath = value;
|
|
170
|
+
else
|
|
171
|
+
writeBaseline = value;
|
|
172
|
+
}
|
|
127
173
|
else if (arg === "--fail-on")
|
|
128
174
|
failOn = severityArg(argv[++i], arg);
|
|
129
175
|
else if (arg === "-h" || arg === "--help") {
|
|
@@ -142,14 +188,34 @@ export async function runScan(argv) {
|
|
|
142
188
|
return 0;
|
|
143
189
|
failOn ??= "high";
|
|
144
190
|
}
|
|
191
|
+
const warnings = [];
|
|
145
192
|
const files = [];
|
|
146
193
|
if (changed) {
|
|
147
194
|
for (const file of changedFiles())
|
|
148
195
|
walk(file, files);
|
|
149
196
|
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
197
|
+
if (paths.length || !changed)
|
|
198
|
+
files.push(...listFiles(paths, warnings));
|
|
199
|
+
if (files.length >= maxFiles)
|
|
200
|
+
warnings.push("file limit reached: some files were not scanned");
|
|
201
|
+
const unique = [...new Set(files)];
|
|
202
|
+
const scanned = scanFiles(unique.filter((file) => candidate.test(file) && !skippedFiles.test(file)), minSeverity);
|
|
203
|
+
const secrets = scanSecrets(unique);
|
|
204
|
+
scanned.findings = scanned.findings.concat(secrets.findings);
|
|
205
|
+
const suppressed = scanned.suppressed + secrets.suppressed;
|
|
206
|
+
warnings.push(...scanned.warnings);
|
|
207
|
+
if (secrets.truncated)
|
|
208
|
+
warnings.push("secret findings capped at 1000");
|
|
209
|
+
if (writeBaseline) {
|
|
210
|
+
const keys = [...new Set(scanned.findings.map(baselineKey))].sort();
|
|
211
|
+
writeFileSync(writeBaseline, `${JSON.stringify({ version: 1, findings: keys }, null, 2)}\n`);
|
|
212
|
+
console.log(`SkaleAgents: wrote ${keys.length} findings to ${writeBaseline}.`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
// A committed baseline at the repo root applies automatically, so hooks and the Action need no flag.
|
|
216
|
+
baselinePath ??= existsSync(".skaleagents-baseline.json") ? ".skaleagents-baseline.json" : undefined;
|
|
217
|
+
const known = baselinePath ? readBaseline(baselinePath) : new Set();
|
|
218
|
+
const findings = scanned.findings.filter((f) => !known.has(baselineKey(f)));
|
|
153
219
|
const failing = failOn ? findings.filter((f) => severityRank[f.severity] >= severityRank[failOn]) : [];
|
|
154
220
|
if (hook) {
|
|
155
221
|
if (failing.length) {
|
package/dist/iac/scan.d.ts
CHANGED
|
@@ -7,6 +7,10 @@ export type ScanOptions = {
|
|
|
7
7
|
minSeverity?: FindingSeverity;
|
|
8
8
|
maxFindings?: number;
|
|
9
9
|
};
|
|
10
|
+
export declare function applySuppressions(findings: Finding[], text: string): {
|
|
11
|
+
kept: Finding[];
|
|
12
|
+
suppressed: number;
|
|
13
|
+
};
|
|
10
14
|
export declare function summarize(findings: Finding[]): {
|
|
11
15
|
critical: number;
|
|
12
16
|
high: number;
|
package/dist/iac/scan.js
CHANGED
|
@@ -11,7 +11,7 @@ export const severityRank = {
|
|
|
11
11
|
const markerLine = /^----- skaleagents-file: (.+) -----$/;
|
|
12
12
|
const ignoreMarker = /skaleagents-ignore[ \t]+([A-Za-z0-9_, \t-]+)/;
|
|
13
13
|
// A `skaleagents-ignore RULE[,RULE]` comment on the finding's line or the line above suppresses it.
|
|
14
|
-
function applySuppressions(findings, text) {
|
|
14
|
+
export function applySuppressions(findings, text) {
|
|
15
15
|
const lines = text.split("\n");
|
|
16
16
|
const ignored = (line, ruleId) => {
|
|
17
17
|
if (!line || !ruleId)
|
package/dist/secrets.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { readFileSync, statSync } from "node:fs";
|
|
4
|
+
import { relative } from "node:path";
|
|
5
|
+
import { applySuppressions } from "./iac/scan.js";
|
|
6
|
+
// High-confidence formats only: a false alarm on every high-entropy string would get the hook uninstalled.
|
|
7
|
+
const patterns = [
|
|
8
|
+
["AWS access key ID", /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
|
|
9
|
+
["GitHub token", /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{50,})\b/g],
|
|
10
|
+
["Slack token", /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
|
|
11
|
+
["Stripe live key", /\b[sr]k_live_[A-Za-z0-9]{20,}\b/g],
|
|
12
|
+
["Google API key", /\bAIza[0-9A-Za-z_-]{35}\b/g],
|
|
13
|
+
["npm token", /\bnpm_[A-Za-z0-9]{36}\b/g],
|
|
14
|
+
["Anthropic API key", /\bsk-ant-[A-Za-z0-9_-]{20,}/g],
|
|
15
|
+
["Private key", /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/g],
|
|
16
|
+
];
|
|
17
|
+
const maxBytes = 1_000_000;
|
|
18
|
+
const maxFindings = 1000;
|
|
19
|
+
const placeholder = /EXAMPLE|YOUR|PLACEHOLDER|XXXX/i;
|
|
20
|
+
const skipped = /(^|[/\\])(package-lock\.json|composer\.lock|pnpm-lock\.yaml|yarn\.lock|\.skaleagents-baseline\.json)$|\.(png|jpe?g|gif|webp|ico|pdf|zip|gz|woff2?|ttf|mp4|mov|wasm|lock|map)$/i;
|
|
21
|
+
function gitIgnored(files) {
|
|
22
|
+
try {
|
|
23
|
+
const out = execFileSync("git", ["check-ignore", "--stdin", "-z"], {
|
|
24
|
+
input: files.join("\0"),
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
stdio: ["pipe", "pipe", "ignore"],
|
|
27
|
+
});
|
|
28
|
+
return new Set(out.split("\0").filter(Boolean));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return new Set(); // Not a git repo, or nothing is ignored (exit 1).
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// The matched text is never copied into a finding. A short digest keeps two keys in one file distinct.
|
|
35
|
+
export function scanSecrets(files) {
|
|
36
|
+
const ignored = gitIgnored(files);
|
|
37
|
+
const findings = [];
|
|
38
|
+
let suppressed = 0;
|
|
39
|
+
for (const file of files) {
|
|
40
|
+
if (skipped.test(file) || ignored.has(file))
|
|
41
|
+
continue;
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
if (statSync(file).size > maxBytes)
|
|
45
|
+
continue;
|
|
46
|
+
text = readFileSync(file, "utf8");
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (text.includes("\0"))
|
|
52
|
+
continue;
|
|
53
|
+
const found = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
const lineStarts = [0];
|
|
56
|
+
for (let i = text.indexOf("\n"); i !== -1; i = text.indexOf("\n", i + 1))
|
|
57
|
+
lineStarts.push(i + 1);
|
|
58
|
+
for (const [name, pattern] of patterns) {
|
|
59
|
+
for (const match of text.matchAll(pattern)) {
|
|
60
|
+
if (placeholder.test(match[0]))
|
|
61
|
+
continue;
|
|
62
|
+
let low = 0;
|
|
63
|
+
let high = lineStarts.length - 1;
|
|
64
|
+
while (low < high) {
|
|
65
|
+
const mid = (low + high + 1) >> 1;
|
|
66
|
+
if (lineStarts[mid] <= match.index)
|
|
67
|
+
low = mid;
|
|
68
|
+
else
|
|
69
|
+
high = mid - 1;
|
|
70
|
+
}
|
|
71
|
+
const line = low + 1;
|
|
72
|
+
// A key block's header is the same for every key, so its digest also covers the start of the body.
|
|
73
|
+
const material = name === "Private key" ? text.slice(match.index, match.index + match[0].length + 64) : match[0];
|
|
74
|
+
const digest = createHash("sha256").update(material).digest("hex").slice(0, 6);
|
|
75
|
+
const resource = `${name} ${digest}`;
|
|
76
|
+
found.push({
|
|
77
|
+
severity: "critical",
|
|
78
|
+
category: "security",
|
|
79
|
+
ruleId: "SECRET001",
|
|
80
|
+
title: `${name} in source`,
|
|
81
|
+
detail: `A ${name.toLowerCase()} is committed in plain text. The value is not shown here.`,
|
|
82
|
+
remediation: "Revoke and rotate it now, remove it from the file and from git history, and load it from a secret manager or environment variable.",
|
|
83
|
+
resource,
|
|
84
|
+
location: { line, column: 1, path: name },
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const applied = applySuppressions(found, text);
|
|
89
|
+
suppressed += applied.suppressed;
|
|
90
|
+
for (const finding of applied.kept) {
|
|
91
|
+
// One finding per distinct secret per file, matching the baseline key, after suppressions so an ignored copy cannot hide another.
|
|
92
|
+
if (seen.has(finding.resource ?? ""))
|
|
93
|
+
continue;
|
|
94
|
+
seen.add(finding.resource ?? "");
|
|
95
|
+
if (findings.length >= maxFindings)
|
|
96
|
+
return { findings, suppressed, truncated: true };
|
|
97
|
+
findings.push({ ...finding, file: relative(".", file) });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { findings, suppressed, truncated: false };
|
|
101
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const VERSION = "0.
|
|
1
|
+
export declare const VERSION = "0.8.0";
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.
|
|
1
|
+
export const VERSION = "0.8.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skaleagents/swarm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -53,5 +53,6 @@
|
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"@types/node": "^22.20.1",
|
|
55
55
|
"tsx": "^4.23.12"
|
|
56
|
-
}
|
|
56
|
+
},
|
|
57
|
+
"mcpName": "io.github.SkaleAgents/swarm"
|
|
57
58
|
}
|