@skaleagents/swarm 0.7.1 → 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 +5 -3
- package/dist/cli.js +40 -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 +1 -1
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ 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
|
|
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.
|
|
25
27
|
|
|
26
28
|
Suppress an intended setting with a comment on the flagged line or the line above: `# skaleagents-ignore DATA001 public asset bucket`.
|
|
27
29
|
|
|
@@ -35,7 +37,7 @@ Suppress an intended setting with a comment on the flagged line or the line abov
|
|
|
35
37
|
**Cursor.** Add `.cursor/hooks.json`:
|
|
36
38
|
|
|
37
39
|
```json
|
|
38
|
-
{ "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" }] } }
|
|
39
41
|
```
|
|
40
42
|
|
|
41
43
|
**GitHub Action.** It posts one PR comment and fails on `critical` by default:
|
|
@@ -49,7 +51,7 @@ jobs:
|
|
|
49
51
|
steps:
|
|
50
52
|
- uses: actions/checkout@v4
|
|
51
53
|
with: { fetch-depth: 0 }
|
|
52
|
-
- uses: SkaleAgents/mcp-server@v0.
|
|
54
|
+
- uses: SkaleAgents/mcp-server@v0.8.0
|
|
53
55
|
with: { fail-on: high }
|
|
54
56
|
```
|
|
55
57
|
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,10 @@ import { execFileSync } from "node:child_process";
|
|
|
2
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)
|
|
@@ -19,7 +20,7 @@ Suppress a finding with a comment on its line or the line above:
|
|
|
19
20
|
const candidate = /\.(tf|tfvars|hcl|ya?ml|json|bicep)$|(^|[/\\])(Dockerfile|Containerfile)[^/\\]*$/;
|
|
20
21
|
const skippedDirs = new Set([".git", "node_modules", "vendor", "dist", "build", ".terraform", ".next"]);
|
|
21
22
|
const skippedFiles = /(^|[/\\])(package-lock\.json|tsconfig[^/\\]*\.json|composer\.lock|pnpm-lock\.yaml|yarn\.lock)$/;
|
|
22
|
-
const maxFiles =
|
|
23
|
+
const maxFiles = 20000;
|
|
23
24
|
const severities = Object.keys(severityRank);
|
|
24
25
|
function walk(path, out) {
|
|
25
26
|
if (out.length >= maxFiles)
|
|
@@ -32,8 +33,7 @@ function walk(path, out) {
|
|
|
32
33
|
return;
|
|
33
34
|
}
|
|
34
35
|
if (stat.isFile()) {
|
|
35
|
-
|
|
36
|
-
out.push(path);
|
|
36
|
+
out.push(path);
|
|
37
37
|
return;
|
|
38
38
|
}
|
|
39
39
|
if (!stat.isDirectory())
|
|
@@ -48,6 +48,29 @@ function git(args) {
|
|
|
48
48
|
.split("\0")
|
|
49
49
|
.filter(Boolean);
|
|
50
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
|
+
}
|
|
51
74
|
export function changedFiles() {
|
|
52
75
|
let tracked = [];
|
|
53
76
|
try {
|
|
@@ -165,15 +188,24 @@ export async function runScan(argv) {
|
|
|
165
188
|
return 0;
|
|
166
189
|
failOn ??= "high";
|
|
167
190
|
}
|
|
191
|
+
const warnings = [];
|
|
168
192
|
const files = [];
|
|
169
193
|
if (changed) {
|
|
170
194
|
for (const file of changedFiles())
|
|
171
195
|
walk(file, files);
|
|
172
196
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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");
|
|
177
209
|
if (writeBaseline) {
|
|
178
210
|
const keys = [...new Set(scanned.findings.map(baselineKey))].sort();
|
|
179
211
|
writeFileSync(writeBaseline, `${JSON.stringify({ version: 1, findings: keys }, null, 2)}\n`);
|
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";
|