@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 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.7.0 scan --hook" }] } }
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.7.0
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 = 2000;
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
- if (candidate.test(path) && !skippedFiles.test(path))
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
- for (const path of paths.length || changed ? paths : ["."])
151
- walk(path, files);
152
- const { findings, suppressed, warnings } = scanFiles([...new Set(files)], minSeverity);
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) {
@@ -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)
@@ -0,0 +1,9 @@
1
+ import type { Finding } from "./review.js";
2
+ export type SecretFinding = Finding & {
3
+ file: string;
4
+ };
5
+ export declare function scanSecrets(files: string[]): {
6
+ findings: SecretFinding[];
7
+ suppressed: number;
8
+ truncated: boolean;
9
+ };
@@ -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.7.0";
1
+ export declare const VERSION = "0.8.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.7.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.7.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
  }