seamshield 0.0.1 → 0.1.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/dist/index.d.ts +2 -0
- package/dist/index.js +1096 -0
- package/package.json +31 -4
- package/rules/secrets.patterns.yaml +28 -0
- package/rules/ss-agent-mcp-inline-credentials.yaml +23 -0
- package/rules/ss-agent-overbroad-permissions.yaml +27 -0
- package/rules/ss-agent-secrets-in-agent-files.yaml +22 -0
- package/rules/ss-auth-admin-route-unprotected.yaml +24 -0
- package/rules/ss-auth-api-route-no-auth.yaml +24 -0
- package/rules/ss-auth-client-only-guard.yaml +24 -0
- package/rules/ss-auth-cors-wildcard-with-credentials.yaml +21 -0
- package/rules/ss-client-firebase-admin-in-client.yaml +23 -0
- package/rules/ss-client-next-public-secret.yaml +33 -0
- package/rules/ss-client-server-secret-env-in-client.yaml +24 -0
- package/rules/ss-client-supabase-service-role-in-client.yaml +24 -0
- package/rules/ss-convex-internal-not-internal.yaml +25 -0
- package/rules/ss-convex-mutation-no-auth.yaml +26 -0
- package/rules/ss-deps-hallucinated-package.yaml +16 -0
- package/rules/ss-deps-known-vuln.yaml +16 -0
- package/rules/ss-deps-no-lockfile.yaml +17 -0
- package/rules/ss-deps-unpinned-spec.yaml +22 -0
- package/rules/ss-firebase-open-rules.yaml +22 -0
- package/rules/ss-secrets-env-file-committed.yaml +21 -0
- package/rules/ss-secrets-generic-credential-assignment.yaml +26 -0
- package/rules/ss-secrets-hardcoded-provider-key.yaml +47 -0
- package/rules/ss-secrets-private-key-file.yaml +22 -0
- package/rules/ss-secrets-supabase-service-role-key.yaml +25 -0
- package/rules/ss-supabase-permissive-policy.yaml +22 -0
- package/rules/ss-supabase-rls-disabled.yaml +22 -0
- package/schemas/finding.schema.json +92 -0
- package/README.md +0 -5
- package/index.js +0 -2
package/dist/index.js
ADDED
|
@@ -0,0 +1,1096 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __commonJS = (cb, mod) => function __require() {
|
|
9
|
+
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
20
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
21
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
22
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
23
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
24
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
25
|
+
mod
|
|
26
|
+
));
|
|
27
|
+
|
|
28
|
+
// ../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js
|
|
29
|
+
var require_picocolors = __commonJS({
|
|
30
|
+
"../../node_modules/.pnpm/picocolors@1.1.1/node_modules/picocolors/picocolors.js"(exports, module) {
|
|
31
|
+
"use strict";
|
|
32
|
+
var p = process || {};
|
|
33
|
+
var argv = p.argv || [];
|
|
34
|
+
var env = p.env || {};
|
|
35
|
+
var isColorSupported = !(!!env.NO_COLOR || argv.includes("--no-color")) && (!!env.FORCE_COLOR || argv.includes("--color") || p.platform === "win32" || (p.stdout || {}).isTTY && env.TERM !== "dumb" || !!env.CI);
|
|
36
|
+
var formatter = (open, close, replace = open) => (input) => {
|
|
37
|
+
let string = "" + input, index = string.indexOf(close, open.length);
|
|
38
|
+
return ~index ? open + replaceClose(string, close, replace, index) + close : open + string + close;
|
|
39
|
+
};
|
|
40
|
+
var replaceClose = (string, close, replace, index) => {
|
|
41
|
+
let result = "", cursor = 0;
|
|
42
|
+
do {
|
|
43
|
+
result += string.substring(cursor, index) + replace;
|
|
44
|
+
cursor = index + close.length;
|
|
45
|
+
index = string.indexOf(close, cursor);
|
|
46
|
+
} while (~index);
|
|
47
|
+
return result + string.substring(cursor);
|
|
48
|
+
};
|
|
49
|
+
var createColors = (enabled = isColorSupported) => {
|
|
50
|
+
let f = enabled ? formatter : () => String;
|
|
51
|
+
return {
|
|
52
|
+
isColorSupported: enabled,
|
|
53
|
+
reset: f("\x1B[0m", "\x1B[0m"),
|
|
54
|
+
bold: f("\x1B[1m", "\x1B[22m", "\x1B[22m\x1B[1m"),
|
|
55
|
+
dim: f("\x1B[2m", "\x1B[22m", "\x1B[22m\x1B[2m"),
|
|
56
|
+
italic: f("\x1B[3m", "\x1B[23m"),
|
|
57
|
+
underline: f("\x1B[4m", "\x1B[24m"),
|
|
58
|
+
inverse: f("\x1B[7m", "\x1B[27m"),
|
|
59
|
+
hidden: f("\x1B[8m", "\x1B[28m"),
|
|
60
|
+
strikethrough: f("\x1B[9m", "\x1B[29m"),
|
|
61
|
+
black: f("\x1B[30m", "\x1B[39m"),
|
|
62
|
+
red: f("\x1B[31m", "\x1B[39m"),
|
|
63
|
+
green: f("\x1B[32m", "\x1B[39m"),
|
|
64
|
+
yellow: f("\x1B[33m", "\x1B[39m"),
|
|
65
|
+
blue: f("\x1B[34m", "\x1B[39m"),
|
|
66
|
+
magenta: f("\x1B[35m", "\x1B[39m"),
|
|
67
|
+
cyan: f("\x1B[36m", "\x1B[39m"),
|
|
68
|
+
white: f("\x1B[37m", "\x1B[39m"),
|
|
69
|
+
gray: f("\x1B[90m", "\x1B[39m"),
|
|
70
|
+
bgBlack: f("\x1B[40m", "\x1B[49m"),
|
|
71
|
+
bgRed: f("\x1B[41m", "\x1B[49m"),
|
|
72
|
+
bgGreen: f("\x1B[42m", "\x1B[49m"),
|
|
73
|
+
bgYellow: f("\x1B[43m", "\x1B[49m"),
|
|
74
|
+
bgBlue: f("\x1B[44m", "\x1B[49m"),
|
|
75
|
+
bgMagenta: f("\x1B[45m", "\x1B[49m"),
|
|
76
|
+
bgCyan: f("\x1B[46m", "\x1B[49m"),
|
|
77
|
+
bgWhite: f("\x1B[47m", "\x1B[49m"),
|
|
78
|
+
blackBright: f("\x1B[90m", "\x1B[39m"),
|
|
79
|
+
redBright: f("\x1B[91m", "\x1B[39m"),
|
|
80
|
+
greenBright: f("\x1B[92m", "\x1B[39m"),
|
|
81
|
+
yellowBright: f("\x1B[93m", "\x1B[39m"),
|
|
82
|
+
blueBright: f("\x1B[94m", "\x1B[39m"),
|
|
83
|
+
magentaBright: f("\x1B[95m", "\x1B[39m"),
|
|
84
|
+
cyanBright: f("\x1B[96m", "\x1B[39m"),
|
|
85
|
+
whiteBright: f("\x1B[97m", "\x1B[39m"),
|
|
86
|
+
bgBlackBright: f("\x1B[100m", "\x1B[49m"),
|
|
87
|
+
bgRedBright: f("\x1B[101m", "\x1B[49m"),
|
|
88
|
+
bgGreenBright: f("\x1B[102m", "\x1B[49m"),
|
|
89
|
+
bgYellowBright: f("\x1B[103m", "\x1B[49m"),
|
|
90
|
+
bgBlueBright: f("\x1B[104m", "\x1B[49m"),
|
|
91
|
+
bgMagentaBright: f("\x1B[105m", "\x1B[49m"),
|
|
92
|
+
bgCyanBright: f("\x1B[106m", "\x1B[49m"),
|
|
93
|
+
bgWhiteBright: f("\x1B[107m", "\x1B[49m")
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
module.exports = createColors();
|
|
97
|
+
module.exports.createColors = createColors;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// src/index.ts
|
|
102
|
+
import { spawnSync as spawnSync2 } from "child_process";
|
|
103
|
+
import { existsSync as existsSync2, mkdirSync, mkdtempSync, readFileSync as readFileSync6, rmSync, writeFileSync } from "fs";
|
|
104
|
+
import { tmpdir } from "os";
|
|
105
|
+
import { dirname as dirname3, join as join7, resolve as resolve2 } from "path";
|
|
106
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
107
|
+
import { Command } from "commander";
|
|
108
|
+
|
|
109
|
+
// ../core/dist/index.js
|
|
110
|
+
import { readFileSync } from "fs";
|
|
111
|
+
import { join as join2 } from "path";
|
|
112
|
+
import { parse } from "yaml";
|
|
113
|
+
import { z } from "zod";
|
|
114
|
+
import { randomUUID } from "crypto";
|
|
115
|
+
import { basename, extname } from "path";
|
|
116
|
+
import { spawnSync } from "child_process";
|
|
117
|
+
import { basename as basename2 } from "path";
|
|
118
|
+
var import_picocolors = __toESM(require_picocolors(), 1);
|
|
119
|
+
import { createHash } from "crypto";
|
|
120
|
+
import { readFileSync as readFileSync2, readdirSync } from "fs";
|
|
121
|
+
import { join as join3 } from "path";
|
|
122
|
+
import { parse as parse2 } from "yaml";
|
|
123
|
+
import { z as z2 } from "zod";
|
|
124
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
125
|
+
import { resolve } from "path";
|
|
126
|
+
|
|
127
|
+
// ../rules/dist/index.js
|
|
128
|
+
import { dirname, join } from "path";
|
|
129
|
+
import { fileURLToPath } from "url";
|
|
130
|
+
var rulesDir = join(dirname(fileURLToPath(import.meta.url)), "..", "rules");
|
|
131
|
+
var findingSchemaPath = join(
|
|
132
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
133
|
+
"..",
|
|
134
|
+
"schemas",
|
|
135
|
+
"finding.schema.json"
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// ../core/dist/index.js
|
|
139
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
|
|
140
|
+
import { dirname as dirname2, join as join4 } from "path";
|
|
141
|
+
import { existsSync } from "fs";
|
|
142
|
+
import { dirname as dirname22, join as join5 } from "path";
|
|
143
|
+
import { readdirSync as readdirSync2, readFileSync as readFileSync4, statSync } from "fs";
|
|
144
|
+
import { join as join6, relative as relative2 } from "path";
|
|
145
|
+
import { z as z3 } from "zod";
|
|
146
|
+
var ConfigSchema = z.object({
|
|
147
|
+
ignore: z.array(z.string()).optional(),
|
|
148
|
+
rules: z.object({ disable: z.array(z.string()).optional() }).optional()
|
|
149
|
+
});
|
|
150
|
+
var EMPTY = { ignorePrefixes: [], disabledRules: /* @__PURE__ */ new Set() };
|
|
151
|
+
function loadConfig(root) {
|
|
152
|
+
let text;
|
|
153
|
+
try {
|
|
154
|
+
text = readFileSync(join2(root, ".seamshield", "config.yaml"), "utf8");
|
|
155
|
+
} catch {
|
|
156
|
+
return EMPTY;
|
|
157
|
+
}
|
|
158
|
+
const parsed = ConfigSchema.parse(parse(text) ?? {});
|
|
159
|
+
return {
|
|
160
|
+
ignorePrefixes: (parsed.ignore ?? []).map(
|
|
161
|
+
(p) => p.replace(/\/\*{1,2}$/, "").replace(/\/$/, "")
|
|
162
|
+
),
|
|
163
|
+
disabledRules: new Set(parsed.rules?.disable ?? [])
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function isIgnored(rel, prefixes) {
|
|
167
|
+
return prefixes.some((p) => rel === p || rel.startsWith(`${p}/`));
|
|
168
|
+
}
|
|
169
|
+
function matchBasenamePattern(name, pattern) {
|
|
170
|
+
if (pattern.startsWith("*")) return name.endsWith(pattern.slice(1));
|
|
171
|
+
if (pattern.endsWith("*")) return name.startsWith(pattern.slice(0, -1));
|
|
172
|
+
return name === pattern;
|
|
173
|
+
}
|
|
174
|
+
function fileMatchesRule(file, check) {
|
|
175
|
+
const name = basename(file.rel);
|
|
176
|
+
if (check.exclude?.basenames?.some((p) => matchBasenamePattern(name, p))) return false;
|
|
177
|
+
if (check.exclude?.dirs) {
|
|
178
|
+
const segments = file.rel.split(/[\\/]/);
|
|
179
|
+
if (check.exclude.dirs.some((d) => segments.includes(d))) return false;
|
|
180
|
+
}
|
|
181
|
+
const include = check.include;
|
|
182
|
+
if (!include || !include.extensions && !include.basenames && !include.path_contains) {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const rel = file.rel.split("\\").join("/");
|
|
186
|
+
if (include.path_contains && !include.path_contains.some((segment) => rel.includes(segment))) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (!include.extensions && !include.basenames) return true;
|
|
190
|
+
const ext = extname(name).toLowerCase();
|
|
191
|
+
if (include.extensions?.includes(ext)) return true;
|
|
192
|
+
if (include.basenames?.some((p) => matchBasenamePattern(name, p))) return true;
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
function redactSecret(value) {
|
|
196
|
+
return `${value.slice(0, 8)}\u2026(${value.length} chars)`;
|
|
197
|
+
}
|
|
198
|
+
function buildFinding(rule, file, line, evidence, ctx) {
|
|
199
|
+
return {
|
|
200
|
+
event_id: randomUUID(),
|
|
201
|
+
event_type: "scan.finding",
|
|
202
|
+
time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
203
|
+
tenant: "local",
|
|
204
|
+
decision: rule.severity === "block" ? "deny" : "scan",
|
|
205
|
+
route: { plane: "evidence", lane: "cpu", reason: [rule.id] },
|
|
206
|
+
engines: [{ name: "seamshield", version: ctx.engineVersion, role: "scanner" }],
|
|
207
|
+
provenance: { policy_bundle_digest: ctx.policyBundleDigest },
|
|
208
|
+
spans: [{ start: line, end: line, label: file, evidence }],
|
|
209
|
+
finding: {
|
|
210
|
+
rule_id: rule.id,
|
|
211
|
+
severity: rule.severity,
|
|
212
|
+
title: rule.title,
|
|
213
|
+
file,
|
|
214
|
+
line,
|
|
215
|
+
fix: rule.fix
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
var IGNORE_RE = /seamshield-ignore(?:[ \t]+([\w/,\- \t]+))?/;
|
|
220
|
+
function isSuppressed(lines, index, ruleId) {
|
|
221
|
+
const candidates = [lines[index], index > 0 ? lines[index - 1] : void 0];
|
|
222
|
+
for (const line of candidates) {
|
|
223
|
+
if (!line) continue;
|
|
224
|
+
const match = IGNORE_RE.exec(line);
|
|
225
|
+
if (!match) continue;
|
|
226
|
+
if (!match[1]) return true;
|
|
227
|
+
if (match[1].split(/[\s,]+/).filter(Boolean).includes(ruleId)) return true;
|
|
228
|
+
}
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
function runRegexRule(rule, files, cache, ctx) {
|
|
232
|
+
const findings = [];
|
|
233
|
+
const patterns = rule.check.patterns ?? [];
|
|
234
|
+
const gate = rule.check.file_contains ? new RegExp(rule.check.file_contains) : null;
|
|
235
|
+
for (const file of files) {
|
|
236
|
+
if (!fileMatchesRule(file, rule.check)) continue;
|
|
237
|
+
const content = cache.read(file.abs);
|
|
238
|
+
if (content === null) continue;
|
|
239
|
+
if (gate && !gate.test(content)) continue;
|
|
240
|
+
const lines = content.split(/\r?\n/);
|
|
241
|
+
const matchedLines = /* @__PURE__ */ new Set();
|
|
242
|
+
for (const pattern of patterns) {
|
|
243
|
+
const re = new RegExp(pattern.regex);
|
|
244
|
+
for (let i = 0; i < lines.length; i++) {
|
|
245
|
+
const line = lines[i];
|
|
246
|
+
if (line === void 0 || matchedLines.has(i)) continue;
|
|
247
|
+
const match = re.exec(line);
|
|
248
|
+
if (!match) continue;
|
|
249
|
+
if (isSuppressed(lines, i, rule.id)) continue;
|
|
250
|
+
matchedLines.add(i);
|
|
251
|
+
const evidence = rule.check.redact ? `${pattern.name}: ${redactSecret(match[0])}` : match[0].slice(0, 120);
|
|
252
|
+
findings.push(buildFinding(rule, file.rel, i + 1, evidence, ctx));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return findings;
|
|
257
|
+
}
|
|
258
|
+
function runAbsenceRule(rule, files, cache, ctx) {
|
|
259
|
+
const findings = [];
|
|
260
|
+
const patterns = (rule.check.patterns ?? []).map((p) => new RegExp(p.regex));
|
|
261
|
+
const gate = rule.check.file_contains ? new RegExp(rule.check.file_contains) : null;
|
|
262
|
+
for (const file of files) {
|
|
263
|
+
if (!fileMatchesRule(file, rule.check)) continue;
|
|
264
|
+
const content = cache.read(file.abs);
|
|
265
|
+
if (content === null) continue;
|
|
266
|
+
if (gate && !gate.test(content)) continue;
|
|
267
|
+
const lines = content.split(/\r?\n/);
|
|
268
|
+
if (patterns.some((re) => lines.some((line) => re.test(line)))) continue;
|
|
269
|
+
if (lines.some((_, i) => isSuppressed(lines, i, rule.id))) continue;
|
|
270
|
+
findings.push(
|
|
271
|
+
buildFinding(rule, file.rel, 1, "no recognized safeguard found in this file", ctx)
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return findings;
|
|
275
|
+
}
|
|
276
|
+
var ALLOWED = /* @__PURE__ */ new Set([".env.example", ".env.sample", ".env.template"]);
|
|
277
|
+
var ENV_FILE_RE = /^\.env(\..+)?$/;
|
|
278
|
+
function checkEnvFileCommitted(rule, ctx) {
|
|
279
|
+
const result = spawnSync(
|
|
280
|
+
"git",
|
|
281
|
+
["-C", ctx.root, "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
282
|
+
{ encoding: "utf8", timeout: 1500 }
|
|
283
|
+
);
|
|
284
|
+
if (result.error || result.status !== 0 || typeof result.stdout !== "string") {
|
|
285
|
+
return [];
|
|
286
|
+
}
|
|
287
|
+
const findings = [];
|
|
288
|
+
for (const rel of result.stdout.split("\n")) {
|
|
289
|
+
if (!rel) continue;
|
|
290
|
+
const name = basename2(rel);
|
|
291
|
+
if (!ENV_FILE_RE.test(name) || ALLOWED.has(name)) continue;
|
|
292
|
+
findings.push(
|
|
293
|
+
buildFinding(rule, rel, 1, "dotenv file is tracked by git or not covered by .gitignore", ctx)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
return findings;
|
|
297
|
+
}
|
|
298
|
+
function promptFor(finding) {
|
|
299
|
+
return [
|
|
300
|
+
`### ${finding.finding.rule_id} (${finding.finding.severity})`,
|
|
301
|
+
`File: ${finding.finding.file}:${finding.finding.line}`,
|
|
302
|
+
`Issue: ${finding.finding.title}`,
|
|
303
|
+
`Fix: ${finding.finding.fix.agent_prompt}`
|
|
304
|
+
].join("\n");
|
|
305
|
+
}
|
|
306
|
+
function buildFixPlan(result) {
|
|
307
|
+
const items = result.findings.map((finding) => ({
|
|
308
|
+
rule_id: finding.finding.rule_id,
|
|
309
|
+
severity: finding.finding.severity,
|
|
310
|
+
title: finding.finding.title,
|
|
311
|
+
file: finding.finding.file,
|
|
312
|
+
line: finding.finding.line,
|
|
313
|
+
evidence: finding.spans[0]?.evidence ?? "",
|
|
314
|
+
fix: finding.finding.fix,
|
|
315
|
+
agent_prompt: promptFor(finding)
|
|
316
|
+
}));
|
|
317
|
+
return {
|
|
318
|
+
schema: "seamshield.fix-plan/v1",
|
|
319
|
+
target: result.target,
|
|
320
|
+
policy_bundle_digest: result.policyBundleDigest,
|
|
321
|
+
summary: { findings_total: result.findings.length },
|
|
322
|
+
items,
|
|
323
|
+
agent_markdown: [
|
|
324
|
+
"# SeamShield Fix Plan",
|
|
325
|
+
"",
|
|
326
|
+
"Apply these fixes without exposing or logging secret values.",
|
|
327
|
+
"",
|
|
328
|
+
...items.map((item) => item.agent_prompt),
|
|
329
|
+
""
|
|
330
|
+
].join("\n")
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
var PatternSchema = z2.object({
|
|
334
|
+
name: z2.string().min(1),
|
|
335
|
+
regex: z2.string().min(1)
|
|
336
|
+
});
|
|
337
|
+
var RuleSchema = z2.object({
|
|
338
|
+
id: z2.string().regex(/^ss\/[a-z-]+\/[a-z0-9-]+$/),
|
|
339
|
+
severity: z2.enum(["block", "high", "warn", "info"]),
|
|
340
|
+
title: z2.string().min(1),
|
|
341
|
+
description: z2.string().min(1),
|
|
342
|
+
framework_ref: z2.string().min(1),
|
|
343
|
+
check: z2.object({
|
|
344
|
+
type: z2.enum(["regex", "absence", "builtin"]),
|
|
345
|
+
builtin: z2.string().optional(),
|
|
346
|
+
include: z2.object({
|
|
347
|
+
extensions: z2.array(z2.string()).optional(),
|
|
348
|
+
basenames: z2.array(z2.string()).optional(),
|
|
349
|
+
path_contains: z2.array(z2.string()).optional()
|
|
350
|
+
}).optional(),
|
|
351
|
+
exclude: z2.object({
|
|
352
|
+
basenames: z2.array(z2.string()).optional(),
|
|
353
|
+
dirs: z2.array(z2.string()).optional()
|
|
354
|
+
}).optional(),
|
|
355
|
+
file_contains: z2.string().optional(),
|
|
356
|
+
patterns: z2.array(PatternSchema).optional(),
|
|
357
|
+
patterns_from: z2.string().optional(),
|
|
358
|
+
redact: z2.boolean().optional()
|
|
359
|
+
}),
|
|
360
|
+
fix: z2.object({
|
|
361
|
+
summary: z2.string().min(1),
|
|
362
|
+
agent_prompt: z2.string().min(1),
|
|
363
|
+
doc_url: z2.string().optional()
|
|
364
|
+
})
|
|
365
|
+
});
|
|
366
|
+
var PatternFileSchema = z2.object({ patterns: z2.array(PatternSchema).min(1) });
|
|
367
|
+
function loadRules(rulesDir2) {
|
|
368
|
+
const yamlFiles = readdirSync(rulesDir2).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml")).sort();
|
|
369
|
+
if (yamlFiles.length === 0) {
|
|
370
|
+
throw new Error(`no rule YAML files found in ${rulesDir2}`);
|
|
371
|
+
}
|
|
372
|
+
const hash = createHash("sha256");
|
|
373
|
+
const contents = /* @__PURE__ */ new Map();
|
|
374
|
+
for (const name of yamlFiles) {
|
|
375
|
+
const text = readFileSync2(join3(rulesDir2, name), "utf8");
|
|
376
|
+
contents.set(name, text);
|
|
377
|
+
hash.update(name);
|
|
378
|
+
hash.update("\n");
|
|
379
|
+
hash.update(text);
|
|
380
|
+
hash.update("\n");
|
|
381
|
+
}
|
|
382
|
+
const rules = [];
|
|
383
|
+
for (const [name, text] of contents) {
|
|
384
|
+
const doc = parse2(text);
|
|
385
|
+
if (typeof doc !== "object" || doc === null || !("id" in doc)) continue;
|
|
386
|
+
const rule = RuleSchema.parse(doc);
|
|
387
|
+
if (rule.check.patterns_from) {
|
|
388
|
+
const source = contents.get(rule.check.patterns_from);
|
|
389
|
+
if (!source) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
`${name}: patterns_from references missing file ${rule.check.patterns_from}`
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
const shared = PatternFileSchema.parse(parse2(source));
|
|
395
|
+
rule.check.patterns = [...shared.patterns, ...rule.check.patterns ?? []];
|
|
396
|
+
}
|
|
397
|
+
if ((rule.check.type === "regex" || rule.check.type === "absence") && !rule.check.patterns?.length) {
|
|
398
|
+
throw new Error(`${name}: ${rule.check.type} rule has no patterns`);
|
|
399
|
+
}
|
|
400
|
+
rules.push(rule);
|
|
401
|
+
}
|
|
402
|
+
return { rules, policyBundleDigest: hash.digest("hex") };
|
|
403
|
+
}
|
|
404
|
+
function renderJson(result) {
|
|
405
|
+
const bySeverity = {};
|
|
406
|
+
for (const f of result.findings) {
|
|
407
|
+
bySeverity[f.finding.severity] = (bySeverity[f.finding.severity] ?? 0) + 1;
|
|
408
|
+
}
|
|
409
|
+
return JSON.stringify(
|
|
410
|
+
{
|
|
411
|
+
schema: "seamshield.findings/v1",
|
|
412
|
+
engine: { name: "seamshield", version: result.engineVersion },
|
|
413
|
+
policy_bundle_digest: result.policyBundleDigest,
|
|
414
|
+
summary: {
|
|
415
|
+
files_scanned: result.filesScanned,
|
|
416
|
+
rules_loaded: result.rulesLoaded,
|
|
417
|
+
findings_total: result.findings.length,
|
|
418
|
+
by_severity: bySeverity
|
|
419
|
+
},
|
|
420
|
+
findings: result.findings
|
|
421
|
+
},
|
|
422
|
+
null,
|
|
423
|
+
2
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
function level(finding) {
|
|
427
|
+
if (finding.finding.severity === "block" || finding.finding.severity === "high") {
|
|
428
|
+
return "error";
|
|
429
|
+
}
|
|
430
|
+
if (finding.finding.severity === "warn") return "warning";
|
|
431
|
+
return "note";
|
|
432
|
+
}
|
|
433
|
+
function renderSarif(result) {
|
|
434
|
+
const ruleIds = [...new Set(result.findings.map((f) => f.finding.rule_id))].sort();
|
|
435
|
+
return JSON.stringify(
|
|
436
|
+
{
|
|
437
|
+
version: "2.1.0",
|
|
438
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
439
|
+
runs: [
|
|
440
|
+
{
|
|
441
|
+
tool: {
|
|
442
|
+
driver: {
|
|
443
|
+
name: "SeamShield",
|
|
444
|
+
informationUri: "https://seamshield.dev",
|
|
445
|
+
semanticVersion: result.engineVersion,
|
|
446
|
+
rules: ruleIds.map((id) => {
|
|
447
|
+
const sample = result.findings.find((f) => f.finding.rule_id === id);
|
|
448
|
+
return {
|
|
449
|
+
id,
|
|
450
|
+
shortDescription: { text: sample?.finding.title ?? id },
|
|
451
|
+
help: { text: sample?.finding.fix.summary ?? "" }
|
|
452
|
+
};
|
|
453
|
+
})
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
results: result.findings.map((finding) => ({
|
|
457
|
+
ruleId: finding.finding.rule_id,
|
|
458
|
+
level: level(finding),
|
|
459
|
+
message: { text: `${finding.finding.title}: ${finding.finding.fix.summary}` },
|
|
460
|
+
locations: [
|
|
461
|
+
{
|
|
462
|
+
physicalLocation: {
|
|
463
|
+
artifactLocation: { uri: finding.finding.file },
|
|
464
|
+
region: { startLine: finding.finding.line }
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
]
|
|
468
|
+
}))
|
|
469
|
+
}
|
|
470
|
+
]
|
|
471
|
+
},
|
|
472
|
+
null,
|
|
473
|
+
2
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
var SEVERITY_COLOR = {
|
|
477
|
+
block: (s) => import_picocolors.default.red(s),
|
|
478
|
+
high: (s) => import_picocolors.default.magenta(s),
|
|
479
|
+
warn: (s) => import_picocolors.default.yellow(s),
|
|
480
|
+
info: (s) => import_picocolors.default.dim(s)
|
|
481
|
+
};
|
|
482
|
+
function renderTable(result) {
|
|
483
|
+
const lines = [];
|
|
484
|
+
lines.push(
|
|
485
|
+
`${import_picocolors.default.bold(`SeamShield v${result.engineVersion}`)}${import_picocolors.default.dim(
|
|
486
|
+
` \u2014 ${result.filesScanned} files, ${result.rulesLoaded} rules`
|
|
487
|
+
)}`
|
|
488
|
+
);
|
|
489
|
+
lines.push("");
|
|
490
|
+
if (result.findings.length === 0) {
|
|
491
|
+
lines.push(import_picocolors.default.green("\u2713 No findings."));
|
|
492
|
+
} else {
|
|
493
|
+
for (const f of result.findings) {
|
|
494
|
+
const sev = SEVERITY_COLOR[f.finding.severity](
|
|
495
|
+
f.finding.severity.toUpperCase().padEnd(5)
|
|
496
|
+
);
|
|
497
|
+
lines.push(`${sev} ${f.finding.rule_id} ${import_picocolors.default.bold(`${f.finding.file}:${f.finding.line}`)}`);
|
|
498
|
+
lines.push(` ${f.finding.title}`);
|
|
499
|
+
const evidence = f.spans[0]?.evidence;
|
|
500
|
+
if (evidence) lines.push(import_picocolors.default.dim(` evidence: ${evidence}`));
|
|
501
|
+
lines.push(` ${import_picocolors.default.cyan("fix:")} ${f.finding.fix.summary}`);
|
|
502
|
+
lines.push("");
|
|
503
|
+
}
|
|
504
|
+
const counts = /* @__PURE__ */ new Map();
|
|
505
|
+
for (const f of result.findings) {
|
|
506
|
+
counts.set(f.finding.severity, (counts.get(f.finding.severity) ?? 0) + 1);
|
|
507
|
+
}
|
|
508
|
+
const parts = [...counts.entries()].map(([sev, n]) => SEVERITY_COLOR[sev](`${n} ${sev}`));
|
|
509
|
+
lines.push(`${import_picocolors.default.bold(`${result.findings.length} findings`)} (${parts.join(", ")})`);
|
|
510
|
+
}
|
|
511
|
+
lines.push(import_picocolors.default.dim(`policy bundle ${result.policyBundleDigest.slice(0, 12)}`));
|
|
512
|
+
return lines.join("\n");
|
|
513
|
+
}
|
|
514
|
+
var DEP_FIELDS = [
|
|
515
|
+
"dependencies",
|
|
516
|
+
"devDependencies",
|
|
517
|
+
"peerDependencies",
|
|
518
|
+
"optionalDependencies"
|
|
519
|
+
];
|
|
520
|
+
function readJson(path) {
|
|
521
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
522
|
+
}
|
|
523
|
+
function dependencyLine(text, name) {
|
|
524
|
+
const needle = `"${name}"`;
|
|
525
|
+
const index = text.indexOf(needle);
|
|
526
|
+
if (index < 0) return 1;
|
|
527
|
+
return text.slice(0, index).split(/\r?\n/).length;
|
|
528
|
+
}
|
|
529
|
+
function collectDependencies(ctx, files) {
|
|
530
|
+
const deps = [];
|
|
531
|
+
for (const file of files) {
|
|
532
|
+
if (!file.rel.endsWith("package.json")) continue;
|
|
533
|
+
let text;
|
|
534
|
+
let pkg2;
|
|
535
|
+
try {
|
|
536
|
+
text = readFileSync3(file.abs, "utf8");
|
|
537
|
+
pkg2 = JSON.parse(text);
|
|
538
|
+
} catch {
|
|
539
|
+
continue;
|
|
540
|
+
}
|
|
541
|
+
for (const field of DEP_FIELDS) {
|
|
542
|
+
const block = pkg2[field];
|
|
543
|
+
if (!block || typeof block !== "object") continue;
|
|
544
|
+
for (const [name, spec] of Object.entries(block)) {
|
|
545
|
+
if (typeof spec !== "string") continue;
|
|
546
|
+
deps.push({ name, spec, manifestRel: file.rel, line: dependencyLine(text, name) });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const seen = /* @__PURE__ */ new Set();
|
|
551
|
+
return deps.filter((dep) => {
|
|
552
|
+
const key = `${dep.name}@${dep.spec}`;
|
|
553
|
+
if (seen.has(key)) return false;
|
|
554
|
+
seen.add(key);
|
|
555
|
+
return true;
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
function cachePath(ctx, kind, key) {
|
|
559
|
+
const safe = encodeURIComponent(key).replace(/[!'()*]/g, "_");
|
|
560
|
+
return join4(ctx.root, ".seamshield", "cache", kind, `${safe}.json`);
|
|
561
|
+
}
|
|
562
|
+
function readCache(ctx, kind, key) {
|
|
563
|
+
try {
|
|
564
|
+
const entry = readJson(cachePath(ctx, kind, key));
|
|
565
|
+
if (!entry.ok) return void 0;
|
|
566
|
+
if (Date.now() - entry.time > 24 * 60 * 60 * 1e3) return void 0;
|
|
567
|
+
return entry.value;
|
|
568
|
+
} catch {
|
|
569
|
+
return void 0;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
function writeCache(ctx, kind, key, value) {
|
|
573
|
+
const path = cachePath(ctx, kind, key);
|
|
574
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
575
|
+
writeFileSync2(path, JSON.stringify({ ok: true, value, time: Date.now() }));
|
|
576
|
+
}
|
|
577
|
+
async function fetchJson(url, init, timeoutMs, fetchImpl) {
|
|
578
|
+
const controller = new AbortController();
|
|
579
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
580
|
+
try {
|
|
581
|
+
const response = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
582
|
+
let body = null;
|
|
583
|
+
try {
|
|
584
|
+
body = await response.json();
|
|
585
|
+
} catch {
|
|
586
|
+
body = null;
|
|
587
|
+
}
|
|
588
|
+
return { status: response.status, body };
|
|
589
|
+
} finally {
|
|
590
|
+
clearTimeout(timer);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async function checkHallucinatedPackages(rule, ctx, files, options = {}) {
|
|
594
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
595
|
+
if (!fetchImpl) return [];
|
|
596
|
+
const timeoutMs = options.timeoutMs ?? 750;
|
|
597
|
+
const findings = [];
|
|
598
|
+
for (const dep of collectDependencies(ctx, files)) {
|
|
599
|
+
const cached = readCache(ctx, "npm", dep.name);
|
|
600
|
+
let exists = cached?.exists;
|
|
601
|
+
if (exists === void 0) {
|
|
602
|
+
try {
|
|
603
|
+
const encoded = dep.name.startsWith("@") ? dep.name.replace("/", "%2F") : dep.name;
|
|
604
|
+
const result = await fetchJson(
|
|
605
|
+
`https://registry.npmjs.org/${encoded}`,
|
|
606
|
+
{ headers: { accept: "application/vnd.npm.install-v1+json" } },
|
|
607
|
+
timeoutMs,
|
|
608
|
+
fetchImpl
|
|
609
|
+
);
|
|
610
|
+
exists = result.status !== 404;
|
|
611
|
+
if (result.status >= 200 && result.status < 500) {
|
|
612
|
+
writeCache(ctx, "npm", dep.name, { exists });
|
|
613
|
+
}
|
|
614
|
+
} catch {
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (exists === false) {
|
|
619
|
+
findings.push(
|
|
620
|
+
buildFinding(rule, dep.manifestRel, dep.line, `${dep.name} returned 404 from npm`, ctx)
|
|
621
|
+
);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return findings;
|
|
625
|
+
}
|
|
626
|
+
function normalizedVersion(spec) {
|
|
627
|
+
const exact = spec.trim().replace(/^[~^]/, "");
|
|
628
|
+
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(exact) ? exact : null;
|
|
629
|
+
}
|
|
630
|
+
function vulnSeverity(vuln) {
|
|
631
|
+
const haystack = JSON.stringify(vuln).toLowerCase();
|
|
632
|
+
return haystack.includes("kev") || haystack.includes("known exploited") ? "block" : "high";
|
|
633
|
+
}
|
|
634
|
+
async function checkKnownVulnerabilities(rule, ctx, files, options = {}) {
|
|
635
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
636
|
+
if (!fetchImpl) return [];
|
|
637
|
+
const deps = collectDependencies(ctx, files).map((dep) => ({ ...dep, version: normalizedVersion(dep.spec) })).filter((dep) => dep.version !== null);
|
|
638
|
+
if (deps.length === 0) return [];
|
|
639
|
+
const timeoutMs = options.timeoutMs ?? 750;
|
|
640
|
+
const findings = [];
|
|
641
|
+
for (const dep of deps) {
|
|
642
|
+
const cacheKey = `${dep.name}@${dep.version}`;
|
|
643
|
+
let vulns = readCache(ctx, "osv", cacheKey);
|
|
644
|
+
if (!vulns) {
|
|
645
|
+
try {
|
|
646
|
+
const result = await fetchJson(
|
|
647
|
+
"https://api.osv.dev/v1/query",
|
|
648
|
+
{
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "content-type": "application/json" },
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
package: { ecosystem: "npm", name: dep.name },
|
|
653
|
+
version: dep.version
|
|
654
|
+
})
|
|
655
|
+
},
|
|
656
|
+
timeoutMs,
|
|
657
|
+
fetchImpl
|
|
658
|
+
);
|
|
659
|
+
if (result.status < 200 || result.status >= 300) continue;
|
|
660
|
+
const body = result.body;
|
|
661
|
+
vulns = body.vulns ?? [];
|
|
662
|
+
writeCache(ctx, "osv", cacheKey, vulns);
|
|
663
|
+
} catch {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
for (const vuln of vulns.slice(0, 3)) {
|
|
668
|
+
const id = typeof vuln.id === "string" ? vuln.id : "OSV";
|
|
669
|
+
const severity = vulnSeverity(vuln);
|
|
670
|
+
findings.push(
|
|
671
|
+
buildFinding(
|
|
672
|
+
{ ...rule, severity },
|
|
673
|
+
dep.manifestRel,
|
|
674
|
+
dep.line,
|
|
675
|
+
`${dep.name}@${dep.version} is affected by ${id}`,
|
|
676
|
+
ctx
|
|
677
|
+
)
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return findings;
|
|
682
|
+
}
|
|
683
|
+
var LOCKFILES = ["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb", "bun.lock"];
|
|
684
|
+
function checkNoLockfile(rule, ctx) {
|
|
685
|
+
if (!existsSync(join5(ctx.root, "package.json"))) return [];
|
|
686
|
+
let dir = ctx.root;
|
|
687
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
688
|
+
if (LOCKFILES.some((name) => existsSync(join5(dir, name)))) return [];
|
|
689
|
+
const parent = dirname22(dir);
|
|
690
|
+
if (parent === dir) break;
|
|
691
|
+
dir = parent;
|
|
692
|
+
}
|
|
693
|
+
return [buildFinding(rule, "package.json", 1, "no lockfile found next to package.json", ctx)];
|
|
694
|
+
}
|
|
695
|
+
var SEVERITY_RANK = {
|
|
696
|
+
block: 0,
|
|
697
|
+
high: 1,
|
|
698
|
+
warn: 2,
|
|
699
|
+
info: 3
|
|
700
|
+
};
|
|
701
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set([
|
|
702
|
+
"node_modules",
|
|
703
|
+
".git",
|
|
704
|
+
"dist",
|
|
705
|
+
".next",
|
|
706
|
+
"build",
|
|
707
|
+
"out",
|
|
708
|
+
"coverage",
|
|
709
|
+
".turbo",
|
|
710
|
+
".vercel",
|
|
711
|
+
".cache",
|
|
712
|
+
".pnpm-store"
|
|
713
|
+
]);
|
|
714
|
+
var MAX_FILE_BYTES = 1e6;
|
|
715
|
+
function walk(root) {
|
|
716
|
+
const files = [];
|
|
717
|
+
const visit = (dir) => {
|
|
718
|
+
let entries;
|
|
719
|
+
try {
|
|
720
|
+
entries = readdirSync2(dir, { withFileTypes: true });
|
|
721
|
+
} catch {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
if (entry.isSymbolicLink()) continue;
|
|
726
|
+
const abs = join6(dir, entry.name);
|
|
727
|
+
if (entry.isDirectory()) {
|
|
728
|
+
const rel = relative2(root, abs).split("\\").join("/");
|
|
729
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
730
|
+
if (entry.name === ".worktrees") continue;
|
|
731
|
+
if (rel === ".claude/worktrees" || rel.endsWith("/.claude/worktrees")) continue;
|
|
732
|
+
visit(abs);
|
|
733
|
+
} else if (entry.isFile()) {
|
|
734
|
+
try {
|
|
735
|
+
if (statSync(abs).size > MAX_FILE_BYTES) continue;
|
|
736
|
+
} catch {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
files.push({ abs, rel: relative2(root, abs) });
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
visit(root);
|
|
744
|
+
return files;
|
|
745
|
+
}
|
|
746
|
+
var FileCache = class {
|
|
747
|
+
cache = /* @__PURE__ */ new Map();
|
|
748
|
+
read(abs) {
|
|
749
|
+
const cached = this.cache.get(abs);
|
|
750
|
+
if (cached !== void 0) return cached;
|
|
751
|
+
let value = null;
|
|
752
|
+
try {
|
|
753
|
+
const buf = readFileSync4(abs);
|
|
754
|
+
value = buf.includes(0) ? null : buf.toString("utf8");
|
|
755
|
+
} catch {
|
|
756
|
+
value = null;
|
|
757
|
+
}
|
|
758
|
+
this.cache.set(abs, value);
|
|
759
|
+
return value;
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
var corePkg = JSON.parse(
|
|
763
|
+
readFileSync5(new URL("../package.json", import.meta.url), "utf8")
|
|
764
|
+
);
|
|
765
|
+
function scan(target, options = {}) {
|
|
766
|
+
const root = resolve(target);
|
|
767
|
+
const { rules, policyBundleDigest } = loadRules(options.rulesDir ?? rulesDir);
|
|
768
|
+
const config = loadConfig(root);
|
|
769
|
+
const ctx = { root, policyBundleDigest, engineVersion: corePkg.version };
|
|
770
|
+
const files = walk(root).filter((f) => !isIgnored(f.rel, config.ignorePrefixes));
|
|
771
|
+
const cache = new FileCache();
|
|
772
|
+
let findings = [];
|
|
773
|
+
for (const rule of rules) {
|
|
774
|
+
if (config.disabledRules.has(rule.id)) continue;
|
|
775
|
+
if (rule.check.type === "regex") {
|
|
776
|
+
findings.push(...runRegexRule(rule, files, cache, ctx));
|
|
777
|
+
} else if (rule.check.type === "absence") {
|
|
778
|
+
findings.push(...runAbsenceRule(rule, files, cache, ctx));
|
|
779
|
+
} else if (rule.check.builtin === "env-file-committed") {
|
|
780
|
+
findings.push(...checkEnvFileCommitted(rule, ctx));
|
|
781
|
+
} else if (rule.check.builtin === "no-lockfile") {
|
|
782
|
+
findings.push(...checkNoLockfile(rule, ctx));
|
|
783
|
+
} else if (rule.check.builtin === "hallucinated-package" || rule.check.builtin === "known-vuln") {
|
|
784
|
+
continue;
|
|
785
|
+
} else {
|
|
786
|
+
throw new Error(`${rule.id}: unknown builtin check "${rule.check.builtin}"`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
findings = findings.filter((f) => !isIgnored(f.finding.file, config.ignorePrefixes));
|
|
790
|
+
findings.sort(
|
|
791
|
+
(a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] || a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.finding.rule_id.localeCompare(b.finding.rule_id)
|
|
792
|
+
);
|
|
793
|
+
return {
|
|
794
|
+
target: root,
|
|
795
|
+
findings,
|
|
796
|
+
exitCode: computeExitCode(findings, options.failOn ?? "block"),
|
|
797
|
+
filesScanned: files.length,
|
|
798
|
+
rulesLoaded: rules.length,
|
|
799
|
+
policyBundleDigest,
|
|
800
|
+
engineVersion: corePkg.version,
|
|
801
|
+
networkSkipped: true
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
async function scanAsync(target, options = {}) {
|
|
805
|
+
const result = scan(target, options);
|
|
806
|
+
if (options.network === "off") return result;
|
|
807
|
+
const root = resolve(target);
|
|
808
|
+
const { rules } = loadRules(options.rulesDir ?? rulesDir);
|
|
809
|
+
const config = loadConfig(root);
|
|
810
|
+
const ctx = {
|
|
811
|
+
root,
|
|
812
|
+
policyBundleDigest: result.policyBundleDigest,
|
|
813
|
+
engineVersion: result.engineVersion
|
|
814
|
+
};
|
|
815
|
+
const files = walk(root).filter((f) => !isIgnored(f.rel, config.ignorePrefixes));
|
|
816
|
+
const dependencyFindings = [];
|
|
817
|
+
for (const rule of rules) {
|
|
818
|
+
if (config.disabledRules.has(rule.id)) continue;
|
|
819
|
+
if (rule.check.builtin === "hallucinated-package") {
|
|
820
|
+
dependencyFindings.push(
|
|
821
|
+
...await checkHallucinatedPackages(rule, ctx, files, {
|
|
822
|
+
fetchImpl: options.fetchImpl,
|
|
823
|
+
timeoutMs: options.networkTimeoutMs
|
|
824
|
+
})
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
if (rule.check.builtin === "known-vuln") {
|
|
828
|
+
dependencyFindings.push(
|
|
829
|
+
...await checkKnownVulnerabilities(rule, ctx, files, {
|
|
830
|
+
fetchImpl: options.fetchImpl,
|
|
831
|
+
timeoutMs: options.networkTimeoutMs
|
|
832
|
+
})
|
|
833
|
+
);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
result.findings = [...result.findings, ...dependencyFindings].filter(
|
|
837
|
+
(f) => !isIgnored(f.finding.file, config.ignorePrefixes)
|
|
838
|
+
);
|
|
839
|
+
result.findings.sort(
|
|
840
|
+
(a, b) => SEVERITY_RANK[a.finding.severity] - SEVERITY_RANK[b.finding.severity] || a.finding.file.localeCompare(b.finding.file) || a.finding.line - b.finding.line || a.finding.rule_id.localeCompare(b.finding.rule_id)
|
|
841
|
+
);
|
|
842
|
+
result.exitCode = computeExitCode(result.findings, options.failOn ?? "block");
|
|
843
|
+
result.networkSkipped = false;
|
|
844
|
+
return result;
|
|
845
|
+
}
|
|
846
|
+
function computeExitCode(findings, failOn) {
|
|
847
|
+
if (failOn === "never") return 0;
|
|
848
|
+
const threshold = SEVERITY_RANK[failOn];
|
|
849
|
+
return findings.some((f) => SEVERITY_RANK[f.finding.severity] <= threshold) ? 1 : 0;
|
|
850
|
+
}
|
|
851
|
+
var FindingSchema = z3.object({
|
|
852
|
+
event_id: z3.string().min(8),
|
|
853
|
+
event_type: z3.literal("scan.finding"),
|
|
854
|
+
time: z3.string().min(20),
|
|
855
|
+
tenant: z3.string().min(1),
|
|
856
|
+
decision: z3.enum(["deny", "scan"]),
|
|
857
|
+
route: z3.object({
|
|
858
|
+
plane: z3.literal("evidence"),
|
|
859
|
+
lane: z3.literal("cpu"),
|
|
860
|
+
reason: z3.array(z3.string()).min(1)
|
|
861
|
+
}),
|
|
862
|
+
engines: z3.array(
|
|
863
|
+
z3.object({
|
|
864
|
+
name: z3.string().min(1),
|
|
865
|
+
version: z3.string().min(1),
|
|
866
|
+
role: z3.string().optional()
|
|
867
|
+
})
|
|
868
|
+
).min(1),
|
|
869
|
+
provenance: z3.object({
|
|
870
|
+
policy_bundle_digest: z3.string().regex(/^[a-f0-9]{64}$/)
|
|
871
|
+
}),
|
|
872
|
+
spans: z3.array(
|
|
873
|
+
z3.object({
|
|
874
|
+
start: z3.number().int().min(1),
|
|
875
|
+
end: z3.number().int().min(1),
|
|
876
|
+
label: z3.string().min(1),
|
|
877
|
+
evidence: z3.string().min(1)
|
|
878
|
+
})
|
|
879
|
+
),
|
|
880
|
+
finding: z3.object({
|
|
881
|
+
rule_id: z3.string().regex(/^ss\/[a-z-]+\/[a-z0-9-]+$/),
|
|
882
|
+
severity: z3.enum(["block", "high", "warn", "info"]),
|
|
883
|
+
title: z3.string().min(1),
|
|
884
|
+
file: z3.string().min(1),
|
|
885
|
+
line: z3.number().int().min(1),
|
|
886
|
+
fix: z3.object({
|
|
887
|
+
summary: z3.string().min(1),
|
|
888
|
+
agent_prompt: z3.string().min(1),
|
|
889
|
+
doc_url: z3.string().optional()
|
|
890
|
+
})
|
|
891
|
+
})
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// src/index.ts
|
|
895
|
+
var pkg = JSON.parse(
|
|
896
|
+
readFileSync6(new URL("../package.json", import.meta.url), "utf8")
|
|
897
|
+
);
|
|
898
|
+
var FORMATS = ["table", "json", "sarif"];
|
|
899
|
+
var FAIL_ON = ["block", "high", "warn", "never"];
|
|
900
|
+
function assertOptions(opts) {
|
|
901
|
+
if (opts.format && !FORMATS.includes(opts.format)) {
|
|
902
|
+
console.error(`seamshield: unknown --format "${opts.format}" (expected: ${FORMATS.join(", ")})`);
|
|
903
|
+
process.exitCode = 2;
|
|
904
|
+
return false;
|
|
905
|
+
}
|
|
906
|
+
if (opts.failOn && !FAIL_ON.includes(opts.failOn)) {
|
|
907
|
+
console.error(`seamshield: unknown --fail-on "${opts.failOn}" (expected: ${FAIL_ON.join(", ")})`);
|
|
908
|
+
process.exitCode = 2;
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
return true;
|
|
912
|
+
}
|
|
913
|
+
function render(format, result) {
|
|
914
|
+
if (format === "json") return renderJson(result);
|
|
915
|
+
if (format === "sarif") return renderSarif(result);
|
|
916
|
+
return renderTable(result);
|
|
917
|
+
}
|
|
918
|
+
async function runScan(path, opts) {
|
|
919
|
+
if (!assertOptions(opts)) return;
|
|
920
|
+
if (!existsSync2(path)) {
|
|
921
|
+
console.error(`seamshield: path not found: ${path}`);
|
|
922
|
+
process.exitCode = 2;
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
try {
|
|
926
|
+
const result = await scanAsync(path, {
|
|
927
|
+
failOn: opts.failOn,
|
|
928
|
+
network: opts.offline ? "off" : "on"
|
|
929
|
+
});
|
|
930
|
+
console.log(render(opts.format, result));
|
|
931
|
+
process.exitCode = result.exitCode;
|
|
932
|
+
} catch (error) {
|
|
933
|
+
console.error(`seamshield: scan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
934
|
+
process.exitCode = 2;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function writeAgentContext(target, kind) {
|
|
938
|
+
const body = [
|
|
939
|
+
"# SEAMSHIELD",
|
|
940
|
+
"",
|
|
941
|
+
"- Run `npx seamshield scan --offline` before committing AI-generated changes.",
|
|
942
|
+
"- Never hardcode provider keys, service-role keys, private keys, or dotenv contents.",
|
|
943
|
+
"- Do not expose server secrets through `NEXT_PUBLIC_*` or client components.",
|
|
944
|
+
"- Do not rely on client-only auth for private data; enforce auth server-side.",
|
|
945
|
+
"- Keep Supabase RLS enabled and Firebase rules closed by default.",
|
|
946
|
+
"- If SeamShield reports findings, apply the generated `npx seamshield fix-plan` prompts.",
|
|
947
|
+
""
|
|
948
|
+
].join("\n");
|
|
949
|
+
if (kind === "cursor") {
|
|
950
|
+
const out2 = join7(target, ".cursor", "rules", "seamshield.mdc");
|
|
951
|
+
mkdirSync(dirname3(out2), { recursive: true });
|
|
952
|
+
writeFileSync(out2, body);
|
|
953
|
+
return out2;
|
|
954
|
+
}
|
|
955
|
+
const out = join7(target, "CLAUDE.md");
|
|
956
|
+
const existing = existsSync2(out) ? readFileSync6(out, "utf8") : "";
|
|
957
|
+
const marker = "# SEAMSHIELD";
|
|
958
|
+
const next = existing.includes(marker) ? existing.replace(/# SEAMSHIELD[\s\S]*?(?=\n# |\n?$)/, body.trimEnd()) : `${existing.trimEnd()}${existing.trim() ? "\n\n" : ""}${body}`;
|
|
959
|
+
writeFileSync(out, next.endsWith("\n") ? next : `${next}
|
|
960
|
+
`);
|
|
961
|
+
return out;
|
|
962
|
+
}
|
|
963
|
+
function currentBin() {
|
|
964
|
+
return fileURLToPath2(import.meta.url);
|
|
965
|
+
}
|
|
966
|
+
function installGuard(target) {
|
|
967
|
+
const settingsPath = join7(target, ".claude", "settings.json");
|
|
968
|
+
mkdirSync(dirname3(settingsPath), { recursive: true });
|
|
969
|
+
const settings = existsSync2(settingsPath) ? JSON.parse(readFileSync6(settingsPath, "utf8")) : {};
|
|
970
|
+
const hooks = settings.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
|
|
971
|
+
const command = `${process.execPath} ${JSON.stringify(currentBin())} guard check`;
|
|
972
|
+
hooks.PreToolUse = [
|
|
973
|
+
{
|
|
974
|
+
matcher: "Write|Edit|MultiEdit|Bash",
|
|
975
|
+
hooks: [{ type: "command", command }]
|
|
976
|
+
}
|
|
977
|
+
];
|
|
978
|
+
settings.hooks = hooks;
|
|
979
|
+
writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}
|
|
980
|
+
`);
|
|
981
|
+
return settingsPath;
|
|
982
|
+
}
|
|
983
|
+
function hookDeny(reason) {
|
|
984
|
+
return {
|
|
985
|
+
hookSpecificOutput: {
|
|
986
|
+
hookEventName: "PreToolUse",
|
|
987
|
+
permissionDecision: "deny",
|
|
988
|
+
permissionDecisionReason: reason
|
|
989
|
+
}
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
function hookAllow(additionalContext) {
|
|
993
|
+
return additionalContext ? { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext } } : {};
|
|
994
|
+
}
|
|
995
|
+
function readStdin() {
|
|
996
|
+
return readFileSync6(0, "utf8");
|
|
997
|
+
}
|
|
998
|
+
function extractToolPayload(input) {
|
|
999
|
+
const tool = String(input.tool_name ?? input.toolName ?? "");
|
|
1000
|
+
const raw = input.tool_input ?? input.toolInput ?? input;
|
|
1001
|
+
return { tool, raw };
|
|
1002
|
+
}
|
|
1003
|
+
function contentFromTool(tool, raw) {
|
|
1004
|
+
if (!/Write|Edit|MultiEdit/.test(tool)) return null;
|
|
1005
|
+
const rel = String(raw.file_path ?? raw.path ?? "proposed.txt");
|
|
1006
|
+
const candidate = raw.content ?? raw.new_string ?? raw.newStr ?? (Array.isArray(raw.edits) ? raw.edits.map((e) => e.new_string ?? "").join("\n") : "");
|
|
1007
|
+
return typeof candidate === "string" && candidate.length > 0 ? { rel, content: candidate } : null;
|
|
1008
|
+
}
|
|
1009
|
+
function bashDecision(command) {
|
|
1010
|
+
if (/git\s+add\s+\.env/.test(command)) return "ss/secrets/env-file-committed: do not stage dotenv files.";
|
|
1011
|
+
if (/curl\b[\s\S]*\|\s*(?:sh|bash)/.test(command)) return "ss/agent/overbroad-permissions: do not pipe curl directly to a shell.";
|
|
1012
|
+
if (/npm\s+(?:i|install|add)\s+(@?[\w.-]+\/?[\w.-]*)/.test(command)) {
|
|
1013
|
+
const name = command.match(/npm\s+(?:i|install|add)\s+(@?[\w.-]+\/?[\w.-]*)/)?.[1];
|
|
1014
|
+
if (name) {
|
|
1015
|
+
const encoded = name.startsWith("@") ? name.replace("/", "%2F") : name;
|
|
1016
|
+
const res = spawnSync2("curl", ["-fsSI", `https://registry.npmjs.org/${encoded}`], {
|
|
1017
|
+
encoding: "utf8",
|
|
1018
|
+
timeout: 750
|
|
1019
|
+
});
|
|
1020
|
+
if (res.status !== 0) return `ss/deps/hallucinated-package: npm package "${name}" did not resolve.`;
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
return null;
|
|
1024
|
+
}
|
|
1025
|
+
function guardCheck() {
|
|
1026
|
+
try {
|
|
1027
|
+
const parsed = JSON.parse(readStdin());
|
|
1028
|
+
const { tool, raw } = extractToolPayload(parsed);
|
|
1029
|
+
if (/Bash/.test(tool)) {
|
|
1030
|
+
const command = String(raw.command ?? "");
|
|
1031
|
+
const deny = bashDecision(command);
|
|
1032
|
+
console.log(JSON.stringify(deny ? hookDeny(deny) : hookAllow()));
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const proposed = contentFromTool(tool, raw);
|
|
1036
|
+
if (!proposed) {
|
|
1037
|
+
console.log(JSON.stringify(hookAllow()));
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const tempRoot = mkdtempSync(join7(tmpdir(), "seamshield-guard-"));
|
|
1041
|
+
const abs = join7(tempRoot, proposed.rel.replace(/^\/+/, ""));
|
|
1042
|
+
mkdirSync(dirname3(abs), { recursive: true });
|
|
1043
|
+
writeFileSync(abs, proposed.content);
|
|
1044
|
+
const result = scan(tempRoot, { network: "off" });
|
|
1045
|
+
rmSync(tempRoot, { recursive: true, force: true });
|
|
1046
|
+
const block = result.findings.find((f) => f.finding.severity === "block");
|
|
1047
|
+
if (block) {
|
|
1048
|
+
console.log(
|
|
1049
|
+
JSON.stringify(
|
|
1050
|
+
hookDeny(`${block.finding.rule_id}: ${block.finding.title}. ${block.finding.fix.summary}`)
|
|
1051
|
+
)
|
|
1052
|
+
);
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
console.log(JSON.stringify(hookAllow()));
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
const logPath = join7(process.cwd(), ".seamshield", "guard.log");
|
|
1058
|
+
try {
|
|
1059
|
+
mkdirSync(dirname3(logPath), { recursive: true });
|
|
1060
|
+
writeFileSync(logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${String(error)}
|
|
1061
|
+
`, { flag: "a" });
|
|
1062
|
+
} catch {
|
|
1063
|
+
}
|
|
1064
|
+
console.log(JSON.stringify(hookAllow("SeamShield guard failed open; run seamshield scan.")));
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
var program = new Command();
|
|
1068
|
+
program.name("seamshield").description("Security scanner for AI-generated apps: finds the flaws vibecoded projects predictably ship.").version(pkg.version);
|
|
1069
|
+
program.command("scan").description("Scan a project directory and report findings").argument("[path]", "directory to scan", ".").option("--format <format>", "output format: table | json | sarif", "table").option("--fail-on <severity>", "exit 1 at or above: block | high | warn | never", "block").option("--offline", "skip npm registry and OSV network checks").action((path, opts) => {
|
|
1070
|
+
return runScan(path, opts);
|
|
1071
|
+
});
|
|
1072
|
+
program.command("fix-plan").description("Write .seamshield/fix-plan.json with agent-ready fix prompts").argument("[path]", "directory to scan", ".").option("--offline", "skip npm registry and OSV network checks").action(async (path, opts) => {
|
|
1073
|
+
if (!existsSync2(path)) {
|
|
1074
|
+
console.error(`seamshield: path not found: ${path}`);
|
|
1075
|
+
process.exitCode = 2;
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const result = await scanAsync(path, { network: opts.offline ? "off" : "on" });
|
|
1079
|
+
const out = join7(resolve2(path), ".seamshield", "fix-plan.json");
|
|
1080
|
+
mkdirSync(dirname3(out), { recursive: true });
|
|
1081
|
+
writeFileSync(out, `${JSON.stringify(buildFixPlan(result), null, 2)}
|
|
1082
|
+
`);
|
|
1083
|
+
console.log(out);
|
|
1084
|
+
process.exitCode = result.exitCode;
|
|
1085
|
+
});
|
|
1086
|
+
program.command("agent-context").description("Write SeamShield agent instructions into CLAUDE.md or Cursor rules").argument("[path]", "project directory", ".").option("--claude", "write CLAUDE.md", false).option("--cursor", "write .cursor/rules/seamshield.mdc", false).action((path, opts) => {
|
|
1087
|
+
const target = resolve2(path);
|
|
1088
|
+
const kind = opts.cursor ? "cursor" : "claude";
|
|
1089
|
+
console.log(writeAgentContext(target, kind));
|
|
1090
|
+
});
|
|
1091
|
+
var guard = program.command("guard").description("Claude Code guard utilities");
|
|
1092
|
+
guard.command("check").description("Read a Claude Code hook event from stdin and allow or deny").action(guardCheck);
|
|
1093
|
+
guard.command("install").description("Install SeamShield Claude Code PreToolUse hooks").argument("[path]", "project directory", ".").action((path) => {
|
|
1094
|
+
console.log(installGuard(resolve2(path)));
|
|
1095
|
+
});
|
|
1096
|
+
program.parse();
|