fullshield 0.1.0 → 0.1.3
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/CHANGELOG.md +109 -3
- package/README.md +48 -6
- package/dist/agent.js +3424 -601
- package/dist/bench/score.js +118 -0
- package/dist/cli/index.js +5857 -2033
- package/dist/compliance.js +31 -2
- package/dist/dashboard.js +50 -9
- package/dist/index.d.ts +152 -2
- package/dist/index.js +5733 -2611
- package/dist/mcp.js +3452 -613
- package/dist/runtime/auto.js +100 -3
- package/dist/runtime/dlp.js +313 -0
- package/dist/runtime/express.js +351 -1
- package/dist/runtime/fastify.js +339 -1
- package/dist/runtime/hono.js +570 -0
- package/dist/runtime/index.js +14 -1
- package/dist/runtime/koa.js +573 -0
- package/dist/runtime/rasp.js +100 -3
- package/dist/runtime/register.js +100 -3
- package/dist/supply.js +87 -1
- package/dist/vex.js +178 -0
- package/package.json +25 -6
- package/rules/CONFIG-DIRLIST-001.md +9 -0
- package/rules/CONFIG-TRUSTPROXY-001.md +11 -0
- package/rules/DLP-CARD-001.md +9 -0
- package/rules/DLP-INTERNAL-001.md +9 -0
- package/rules/DLP-PII-001.md +9 -0
- package/rules/DLP-SECRET-001.md +9 -0
- package/rules/DLP-STACK-001.md +9 -0
- package/rules/HOST-SSH-001.md +13 -0
- package/rules/HOST-SSH-002.md +11 -0
- package/rules/HOST-SSH-003.md +11 -0
- package/rules/MCP-001.md +7 -0
- package/rules/MCP-002.md +7 -0
- package/rules/MCP-003.md +7 -0
- package/rules/MCP-004.md +7 -0
- package/rules/MCP-005.md +7 -0
- package/rules/README.md +47 -1
- package/rules/SAST-COOKIE-001.md +11 -0
- package/rules/SAST-CSRF-001.md +11 -0
- package/rules/SAST-DESER-001.md +11 -0
- package/rules/SAST-GQL-001.md +11 -0
- package/rules/SAST-HASH-001.md +11 -0
- package/rules/SAST-MASSASSIGN-001.md +11 -0
- package/rules/SAST-NOSQLI-001.md +6 -2
- package/rules/SAST-REDOS-001.md +11 -0
- package/rules/SAST-TIMING-001.md +11 -0
- package/rules/SAST-UPLOAD-001.md +11 -0
- package/rules/SAST-XXE-001.md +11 -0
- package/rules/SECRET-BUNDLE-001.md +11 -0
- package/rules/SECRET-SESSION-001.md +9 -0
- package/rules/SUPPLY-WORKFLOW-001.md +9 -0
- package/rules/SUPPLY-WORM-001.md +9 -0
- package/templates/github-watch.yml +3 -3
- package/templates/github-workflow.yml +3 -3
package/dist/runtime/register.js
CHANGED
|
@@ -452,9 +452,25 @@ function sinkTexts(sink) {
|
|
|
452
452
|
if (typeof sink === "number" || typeof sink === "boolean") return [String(sink)];
|
|
453
453
|
return [];
|
|
454
454
|
}
|
|
455
|
+
function whereJsChangesStructure(sink, ctx) {
|
|
456
|
+
let code = "";
|
|
457
|
+
if (typeof sink === "string") code = sink;
|
|
458
|
+
else if (sink !== null && typeof sink === "object" && !Array.isArray(sink) && "$where" in sink) {
|
|
459
|
+
const where = sink.$where;
|
|
460
|
+
if (typeof where === "string") code = where;
|
|
461
|
+
}
|
|
462
|
+
if (!code) return false;
|
|
463
|
+
for (const input of ctx.strings) {
|
|
464
|
+
if (input && sqlInputChangesStructure(code, input)) return true;
|
|
465
|
+
}
|
|
466
|
+
return false;
|
|
467
|
+
}
|
|
455
468
|
function detectStructureChange(kind, sink, ctx, base) {
|
|
456
469
|
if (kind === "proto") return protoPolluted(ctx);
|
|
457
|
-
if (kind === "nosql")
|
|
470
|
+
if (kind === "nosql") {
|
|
471
|
+
if (nosqlUserOperators(sink, ctx)) return true;
|
|
472
|
+
return whereJsChangesStructure(sink, ctx);
|
|
473
|
+
}
|
|
458
474
|
for (const text of sinkTexts(sink)) {
|
|
459
475
|
for (const input of ctx.strings) {
|
|
460
476
|
if (!input) continue;
|
|
@@ -756,11 +772,38 @@ function patchMethod(obj, key, wrapper) {
|
|
|
756
772
|
record[key] = orig;
|
|
757
773
|
});
|
|
758
774
|
}
|
|
775
|
+
function sqlTextFrom(sql) {
|
|
776
|
+
if (typeof sql === "string") return sql;
|
|
777
|
+
if (Array.isArray(sql)) {
|
|
778
|
+
return sql.filter((part) => typeof part === "string").join(" ");
|
|
779
|
+
}
|
|
780
|
+
if (sql !== null && typeof sql === "object") {
|
|
781
|
+
const rec = sql;
|
|
782
|
+
if (typeof rec.text === "string") return rec.text;
|
|
783
|
+
if (typeof rec.sql === "string") return rec.sql;
|
|
784
|
+
if (Array.isArray(rec.strings)) {
|
|
785
|
+
return rec.strings.filter((part) => typeof part === "string").join(" ");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return "";
|
|
789
|
+
}
|
|
759
790
|
function wrapSqlMethod(obj, key) {
|
|
760
791
|
patchMethod(obj, key, (orig) => {
|
|
761
792
|
return function(sql, ...rest) {
|
|
762
|
-
|
|
763
|
-
|
|
793
|
+
return applySink(
|
|
794
|
+
"sql",
|
|
795
|
+
sqlTextFrom(sql),
|
|
796
|
+
void 0,
|
|
797
|
+
() => orig.call(this, sql, ...rest)
|
|
798
|
+
);
|
|
799
|
+
};
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
function wrapNosqlWhere(obj, key) {
|
|
803
|
+
patchMethod(obj, key, (orig) => {
|
|
804
|
+
return function(expr, ...rest) {
|
|
805
|
+
const sink = typeof expr === "string" ? { $where: expr } : expr ?? {};
|
|
806
|
+
return applySink("nosql", sink, void 0, () => orig.call(this, expr, ...rest));
|
|
764
807
|
};
|
|
765
808
|
});
|
|
766
809
|
}
|
|
@@ -1051,6 +1094,57 @@ function wrapKnownPackage(request, exported) {
|
|
|
1051
1094
|
wrappedPackages.add(obj);
|
|
1052
1095
|
return exported;
|
|
1053
1096
|
}
|
|
1097
|
+
if (name === "@prisma/client" || name === "prisma" || name.endsWith("/@prisma/client")) {
|
|
1098
|
+
const client = exported.PrismaClient?.prototype;
|
|
1099
|
+
if (client) {
|
|
1100
|
+
wrapSqlMethod(client, "$queryRawUnsafe");
|
|
1101
|
+
wrapSqlMethod(client, "$executeRawUnsafe");
|
|
1102
|
+
wrapSqlMethod(client, "$queryRaw");
|
|
1103
|
+
wrapSqlMethod(client, "$executeRaw");
|
|
1104
|
+
}
|
|
1105
|
+
const Prisma = exported.Prisma;
|
|
1106
|
+
if (Prisma) wrapSqlMethod(Prisma, "raw");
|
|
1107
|
+
wrappedPackages.add(obj);
|
|
1108
|
+
return exported;
|
|
1109
|
+
}
|
|
1110
|
+
if (name === "sequelize" || name.endsWith("/sequelize")) {
|
|
1111
|
+
const rec = exported;
|
|
1112
|
+
const proto = rec.Sequelize?.prototype ?? rec.prototype;
|
|
1113
|
+
if (proto) wrapSqlMethod(proto, "query");
|
|
1114
|
+
if (typeof rec.literal === "function") wrapSqlMethod(rec, "literal");
|
|
1115
|
+
if (rec.Sequelize && typeof rec.Sequelize.literal === "function") wrapSqlMethod(rec.Sequelize, "literal");
|
|
1116
|
+
wrappedPackages.add(obj);
|
|
1117
|
+
return exported;
|
|
1118
|
+
}
|
|
1119
|
+
if (name === "knex" || name.endsWith("/knex")) {
|
|
1120
|
+
if (typeof exported.raw === "function") wrapSqlMethod(obj, "raw");
|
|
1121
|
+
const client = exported.Client?.prototype;
|
|
1122
|
+
if (client) {
|
|
1123
|
+
wrapSqlMethod(client, "raw");
|
|
1124
|
+
wrapSqlMethod(client, "whereRaw");
|
|
1125
|
+
wrapSqlMethod(client, "orderByRaw");
|
|
1126
|
+
wrapSqlMethod(client, "query");
|
|
1127
|
+
}
|
|
1128
|
+
wrappedPackages.add(obj);
|
|
1129
|
+
return exported;
|
|
1130
|
+
}
|
|
1131
|
+
if (name === "typeorm" || name.endsWith("/typeorm")) {
|
|
1132
|
+
const rec = exported;
|
|
1133
|
+
for (const cls of ["DataSource", "EntityManager", "QueryRunner", "Connection"]) {
|
|
1134
|
+
const proto = rec[cls]?.prototype;
|
|
1135
|
+
if (proto) wrapSqlMethod(proto, "query");
|
|
1136
|
+
}
|
|
1137
|
+
const qb = rec.SelectQueryBuilder?.prototype ?? rec.QueryBuilder?.prototype;
|
|
1138
|
+
if (qb) wrapSqlMethod(qb, "where");
|
|
1139
|
+
wrappedPackages.add(obj);
|
|
1140
|
+
return exported;
|
|
1141
|
+
}
|
|
1142
|
+
if (name === "drizzle-orm" || name.endsWith("/drizzle-orm")) {
|
|
1143
|
+
const sql = exported.sql;
|
|
1144
|
+
if (sql) wrapSqlMethod(sql, "raw");
|
|
1145
|
+
wrappedPackages.add(obj);
|
|
1146
|
+
return exported;
|
|
1147
|
+
}
|
|
1054
1148
|
if (name === "mongodb" || name.includes("mongodb")) {
|
|
1055
1149
|
const col = exported.Collection?.prototype;
|
|
1056
1150
|
if (col) {
|
|
@@ -1071,7 +1165,10 @@ function wrapKnownPackage(request, exported) {
|
|
|
1071
1165
|
return applySink("nosql", query ?? {}, void 0, () => orig.call(this, query, ...rest));
|
|
1072
1166
|
};
|
|
1073
1167
|
});
|
|
1168
|
+
wrapNosqlWhere(q, "$where");
|
|
1074
1169
|
}
|
|
1170
|
+
const model = exported.Model?.prototype;
|
|
1171
|
+
if (model) wrapNosqlWhere(model, "$where");
|
|
1075
1172
|
wrappedPackages.add(obj);
|
|
1076
1173
|
return exported;
|
|
1077
1174
|
}
|
package/dist/supply.js
CHANGED
|
@@ -8,6 +8,11 @@ function makeFindingId(ruleId, file, snippet) {
|
|
|
8
8
|
return createHash("sha256").update(`${ruleId}\0${file ?? ""}\0${normalized}`).digest("hex").slice(0, 16);
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
// src/supply/experimental.ts
|
|
12
|
+
function experimentalVex() {
|
|
13
|
+
return process.env.FULLSHIELD_EXPERIMENTAL_VEX === "1";
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
// src/supply/index.ts
|
|
12
17
|
var REGISTRY_HOST = "registry.npmjs.org";
|
|
13
18
|
var INSTALL_SCRIPT_KEYS = ["preinstall", "install", "postinstall"];
|
|
@@ -114,6 +119,10 @@ function analyzePackageDir(dir) {
|
|
|
114
119
|
);
|
|
115
120
|
}
|
|
116
121
|
}
|
|
122
|
+
if (experimentalVex()) {
|
|
123
|
+
findings.push(...analyzeWormIndicators(dir));
|
|
124
|
+
findings.push(...analyzeWorkflowTampering(dir));
|
|
125
|
+
}
|
|
117
126
|
return findings;
|
|
118
127
|
}
|
|
119
128
|
function diffPackageVersions(oldDir, newDir) {
|
|
@@ -224,5 +233,82 @@ async function installGuard(spec, opts = {}) {
|
|
|
224
233
|
}
|
|
225
234
|
return { findings };
|
|
226
235
|
}
|
|
236
|
+
var TOKEN_RE = /(?:~\/)?\.npmrc\b|\bNPM_TOKEN\b|\bGITHUB_TOKEN\b/;
|
|
237
|
+
var PUBLISH_RE = /\bnpm\s+publish\b|\bnpmPublish\b|\blibnpmpublish\b/;
|
|
238
|
+
var REGISTRY_PUT_RE = /registry\.npmjs\.org[\s\S]{0,800}\bPUT\b|\bmethod\s*:\s*['"]PUT['"][\s\S]{0,400}registry\.npmjs\.org/;
|
|
239
|
+
var GITHUB_CREATE_RE = /api\.github\.com\/(?:user\/repos|repos\/)|\/actions\/workflows/;
|
|
240
|
+
var WORKFLOW_PATH_RE = /\.github\/workflows/;
|
|
241
|
+
var CI_WRITE_RE = /\.github\/|\.git\/hooks\/|\.gitlab-ci\.yml|\.travis\.yml|azure-pipelines(?:\.yml)?|Jenkinsfile|\.circleci\//;
|
|
242
|
+
function packageSources(dir) {
|
|
243
|
+
const manifestPath = join(dir, "package.json");
|
|
244
|
+
let scripts = "";
|
|
245
|
+
if (existsSync(manifestPath) && statSync(manifestPath).isFile()) {
|
|
246
|
+
try {
|
|
247
|
+
const pkg = readJson(manifestPath);
|
|
248
|
+
const block = pkg.scripts ?? {};
|
|
249
|
+
scripts = Object.values(block).join("\n");
|
|
250
|
+
} catch {
|
|
251
|
+
scripts = "";
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const sources = walkJs(dir).map((file) => ({
|
|
255
|
+
file: relative(dir, file) || file,
|
|
256
|
+
text: readFileSync(file, "utf8")
|
|
257
|
+
}));
|
|
258
|
+
return { sources, scripts };
|
|
259
|
+
}
|
|
260
|
+
function analyzeWormIndicators(dir) {
|
|
261
|
+
const { sources, scripts } = packageSources(dir);
|
|
262
|
+
if (sources.length === 0 && scripts.length === 0) return [];
|
|
263
|
+
const text = `${sources.map((s) => s.text).join("\n")}
|
|
264
|
+
${scripts}`;
|
|
265
|
+
if (!TOKEN_RE.test(text)) return [];
|
|
266
|
+
const replicates = PUBLISH_RE.test(text) || REGISTRY_PUT_RE.test(text) || GITHUB_CREATE_RE.test(text) || WORKFLOW_PATH_RE.test(text);
|
|
267
|
+
if (!replicates) return [];
|
|
268
|
+
const hit = sources.find(
|
|
269
|
+
(s) => TOKEN_RE.test(s.text) && (PUBLISH_RE.test(s.text) || REGISTRY_PUT_RE.test(s.text) || GITHUB_CREATE_RE.test(s.text) || WORKFLOW_PATH_RE.test(s.text))
|
|
270
|
+
) ?? sources.find((s) => TOKEN_RE.test(s.text));
|
|
271
|
+
return [
|
|
272
|
+
finding(
|
|
273
|
+
"SUPPLY-WORM-001",
|
|
274
|
+
"Self-replicating worm indicators in dependency code",
|
|
275
|
+
"static: npm/GitHub token read combined with publish, registry PUT, GitHub repo/workflow creation, or writing .github/workflows",
|
|
276
|
+
hit?.file,
|
|
277
|
+
"critical"
|
|
278
|
+
)
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
function analyzeWorkflowTampering(dir) {
|
|
282
|
+
const { sources, scripts } = packageSources(dir);
|
|
283
|
+
const text = `${sources.map((s) => s.text).join("\n")}
|
|
284
|
+
${scripts}`;
|
|
285
|
+
if (!CI_WRITE_RE.test(text)) return [];
|
|
286
|
+
const hit = sources.find((s) => CI_WRITE_RE.test(s.text));
|
|
287
|
+
return [
|
|
288
|
+
finding(
|
|
289
|
+
"SUPPLY-WORKFLOW-001",
|
|
290
|
+
"Dependency writes GitHub workflow, git hooks, or CI config",
|
|
291
|
+
"static: install script or package code writes .github/, .git/hooks/, or CI configuration",
|
|
292
|
+
hit?.file ?? (CI_WRITE_RE.test(scripts) ? "package.json" : void 0),
|
|
293
|
+
"critical"
|
|
294
|
+
)
|
|
295
|
+
];
|
|
296
|
+
}
|
|
297
|
+
function provenanceCoverage(packages, known) {
|
|
298
|
+
let withProvenance = 0;
|
|
299
|
+
let unknown = 0;
|
|
300
|
+
for (const pkg of packages) {
|
|
301
|
+
const hit = known.get(`${pkg.name}@${pkg.version}`);
|
|
302
|
+
if (hit === true) withProvenance += 1;
|
|
303
|
+
else if (hit === void 0) unknown += 1;
|
|
304
|
+
}
|
|
305
|
+
const total = packages.length;
|
|
306
|
+
return {
|
|
307
|
+
total,
|
|
308
|
+
withProvenance,
|
|
309
|
+
unknown,
|
|
310
|
+
percent: total === 0 ? 0 : withProvenance / total * 100
|
|
311
|
+
};
|
|
312
|
+
}
|
|
227
313
|
|
|
228
|
-
export { analyzePackageDir, assertRegistryUrl, checkCooldown, checkDependencyConfusion, checkLockfileIntegrity, checkProvenance, diffPackageVersions, installGuard, packageNameFromSpec };
|
|
314
|
+
export { analyzePackageDir, analyzeWorkflowTampering, analyzeWormIndicators, assertRegistryUrl, checkCooldown, checkDependencyConfusion, checkLockfileIntegrity, checkProvenance, diffPackageVersions, installGuard, packageNameFromSpec, provenanceCoverage };
|
package/dist/vex.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
|
|
3
|
+
// src/vex/index.ts
|
|
4
|
+
|
|
5
|
+
// src/sbom/cyclonedx.ts
|
|
6
|
+
function validateCycloneDx15(doc) {
|
|
7
|
+
const errors = [];
|
|
8
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
9
|
+
return ["CycloneDX document must be an object"];
|
|
10
|
+
}
|
|
11
|
+
const root = doc;
|
|
12
|
+
if (root.bomFormat !== "CycloneDX") errors.push('bomFormat must be "CycloneDX"');
|
|
13
|
+
if (root.specVersion !== "1.5") errors.push('specVersion must be "1.5"');
|
|
14
|
+
if (!Array.isArray(root.components)) errors.push("components must be an array");
|
|
15
|
+
return errors;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/vex/index.ts
|
|
19
|
+
var OPENVEX_CONTEXT = "https://openvex.dev/ns/v0.2.0";
|
|
20
|
+
var OPENVEX_NOT_AFFECTED_JUSTIFICATION = "vulnerable_code_not_in_execute_path";
|
|
21
|
+
var CDX_NOT_AFFECTED_JUSTIFICATION = "code_not_reachable";
|
|
22
|
+
var OPENVEX_STATUSES = /* @__PURE__ */ new Set(["not_affected", "affected", "fixed", "under_investigation"]);
|
|
23
|
+
function depFindings(findings) {
|
|
24
|
+
return findings.filter((f) => f.ruleId === "DEPS-VULN-001");
|
|
25
|
+
}
|
|
26
|
+
function vulnName(finding) {
|
|
27
|
+
return finding.references?.find((r) => r.startsWith("CVE-") || r.startsWith("GHSA-") || r.startsWith("MAL-")) ?? finding.id;
|
|
28
|
+
}
|
|
29
|
+
function npmPurl(snippet) {
|
|
30
|
+
const raw = snippet ?? "unknown@0.0.0";
|
|
31
|
+
let name;
|
|
32
|
+
let version;
|
|
33
|
+
if (raw.startsWith("@")) {
|
|
34
|
+
const second = raw.indexOf("@", 1);
|
|
35
|
+
name = second === -1 ? raw : raw.slice(0, second);
|
|
36
|
+
version = second === -1 ? "0.0.0" : raw.slice(second + 1);
|
|
37
|
+
} else {
|
|
38
|
+
const at = raw.indexOf("@");
|
|
39
|
+
name = at <= 0 ? raw : raw.slice(0, at);
|
|
40
|
+
version = at <= 0 ? "0.0.0" : raw.slice(at + 1);
|
|
41
|
+
}
|
|
42
|
+
return `pkg:npm/${encodeURIComponent(name)}@${version}`;
|
|
43
|
+
}
|
|
44
|
+
function notImported(finding) {
|
|
45
|
+
return finding.reachability === "not-imported";
|
|
46
|
+
}
|
|
47
|
+
function openVexStatement(finding) {
|
|
48
|
+
const base = {
|
|
49
|
+
vulnerability: { name: vulnName(finding) },
|
|
50
|
+
products: [{ "@id": npmPurl(finding.snippet) }]
|
|
51
|
+
};
|
|
52
|
+
if (notImported(finding)) {
|
|
53
|
+
return {
|
|
54
|
+
...base,
|
|
55
|
+
status: "not_affected",
|
|
56
|
+
justification: OPENVEX_NOT_AFFECTED_JUSTIFICATION
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { ...base, status: "under_investigation" };
|
|
60
|
+
}
|
|
61
|
+
function buildOpenVex(findings) {
|
|
62
|
+
const statements = depFindings(findings).map(openVexStatement);
|
|
63
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
64
|
+
const idSeed = JSON.stringify(statements);
|
|
65
|
+
const digest = createHash("sha256").update(idSeed).digest("hex").slice(0, 16);
|
|
66
|
+
return {
|
|
67
|
+
"@context": OPENVEX_CONTEXT,
|
|
68
|
+
"@id": `https://openvex.dev/docs/public/fullshield-${digest}`,
|
|
69
|
+
author: "FullShield",
|
|
70
|
+
timestamp,
|
|
71
|
+
version: 1,
|
|
72
|
+
statements
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function validateOpenVex(doc) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) {
|
|
78
|
+
return ["OpenVEX document must be an object"];
|
|
79
|
+
}
|
|
80
|
+
const root = doc;
|
|
81
|
+
if (typeof root["@context"] !== "string" || !String(root["@context"]).includes("openvex")) {
|
|
82
|
+
errors.push("@context must be an OpenVEX context URI");
|
|
83
|
+
}
|
|
84
|
+
if (typeof root["@id"] !== "string" || root["@id"].length === 0) errors.push("@id is required");
|
|
85
|
+
if (typeof root.author !== "string" || root.author.length === 0) errors.push("author is required");
|
|
86
|
+
if (typeof root.timestamp !== "string" || root.timestamp.length === 0) errors.push("timestamp is required");
|
|
87
|
+
if (typeof root.version !== "number") errors.push("version must be a number");
|
|
88
|
+
if (!Array.isArray(root.statements)) {
|
|
89
|
+
errors.push("statements must be an array");
|
|
90
|
+
return errors;
|
|
91
|
+
}
|
|
92
|
+
for (const [i, raw] of root.statements.entries()) {
|
|
93
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
94
|
+
errors.push(`statements[${i}] must be an object`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const stmt = raw;
|
|
98
|
+
if (typeof stmt.vulnerability !== "object" || stmt.vulnerability === null) {
|
|
99
|
+
errors.push(`statements[${i}].vulnerability is required`);
|
|
100
|
+
}
|
|
101
|
+
if (!Array.isArray(stmt.products) || stmt.products.length === 0) {
|
|
102
|
+
errors.push(`statements[${i}].products must be a non-empty array`);
|
|
103
|
+
}
|
|
104
|
+
if (typeof stmt.status !== "string" || !OPENVEX_STATUSES.has(stmt.status)) {
|
|
105
|
+
errors.push(`statements[${i}].status is invalid`);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (stmt.status === "not_affected") {
|
|
109
|
+
if (stmt.justification !== OPENVEX_NOT_AFFECTED_JUSTIFICATION) {
|
|
110
|
+
errors.push(
|
|
111
|
+
`statements[${i}]: not_affected requires justification ${OPENVEX_NOT_AFFECTED_JUSTIFICATION}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return errors;
|
|
117
|
+
}
|
|
118
|
+
function cdxAnalysis(finding) {
|
|
119
|
+
if (notImported(finding)) {
|
|
120
|
+
return { state: "not_affected", justification: CDX_NOT_AFFECTED_JUSTIFICATION };
|
|
121
|
+
}
|
|
122
|
+
return { state: "in_triage" };
|
|
123
|
+
}
|
|
124
|
+
function buildCycloneDxVex(meta, findings) {
|
|
125
|
+
return {
|
|
126
|
+
$schema: "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
127
|
+
bomFormat: "CycloneDX",
|
|
128
|
+
specVersion: "1.5",
|
|
129
|
+
version: 1,
|
|
130
|
+
metadata: {
|
|
131
|
+
component: { type: "application", name: meta.name, version: meta.version }
|
|
132
|
+
},
|
|
133
|
+
components: [],
|
|
134
|
+
vulnerabilities: depFindings(findings).map((f) => ({
|
|
135
|
+
id: vulnName(f),
|
|
136
|
+
description: f.message,
|
|
137
|
+
affects: [{ ref: npmPurl(f.snippet) }],
|
|
138
|
+
analysis: cdxAnalysis(f)
|
|
139
|
+
}))
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function validateCycloneDxVex(doc) {
|
|
143
|
+
const errors = validateCycloneDx15(doc);
|
|
144
|
+
if (typeof doc !== "object" || doc === null || Array.isArray(doc)) return errors;
|
|
145
|
+
const root = doc;
|
|
146
|
+
if (!Array.isArray(root.vulnerabilities)) {
|
|
147
|
+
errors.push("vulnerabilities must be an array");
|
|
148
|
+
return errors;
|
|
149
|
+
}
|
|
150
|
+
for (const [i, raw] of root.vulnerabilities.entries()) {
|
|
151
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
152
|
+
errors.push(`vulnerabilities[${i}] must be an object`);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const vuln = raw;
|
|
156
|
+
if (typeof vuln.analysis !== "object" || vuln.analysis === null || Array.isArray(vuln.analysis)) {
|
|
157
|
+
errors.push(`vulnerabilities[${i}].analysis is required`);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const analysis = vuln.analysis;
|
|
161
|
+
if (typeof analysis.state !== "string" || analysis.state.length === 0) {
|
|
162
|
+
errors.push(`vulnerabilities[${i}].analysis.state is required`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (analysis.state === "not_affected" && analysis.justification !== CDX_NOT_AFFECTED_JUSTIFICATION) {
|
|
166
|
+
errors.push(
|
|
167
|
+
`vulnerabilities[${i}]: not_affected requires justification ${CDX_NOT_AFFECTED_JUSTIFICATION}`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return errors;
|
|
172
|
+
}
|
|
173
|
+
function buildVex(findings, format, meta = { name: "app", version: "0.0.0" }) {
|
|
174
|
+
if (format === "cyclonedx") return buildCycloneDxVex(meta, findings);
|
|
175
|
+
return buildOpenVex(findings);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export { buildCycloneDxVex, buildOpenVex, buildVex, validateCycloneDxVex, validateOpenVex };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullshield",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Full-cycle security scanner for Node.js/TypeScript backends: detect, classify, report, notify, fix, prevent.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
"./runtime": "./dist/runtime/index.js",
|
|
25
25
|
"./runtime/express": "./dist/runtime/express.js",
|
|
26
26
|
"./runtime/fastify": "./dist/runtime/fastify.js",
|
|
27
|
+
"./runtime/koa": "./dist/runtime/koa.js",
|
|
28
|
+
"./runtime/hono": "./dist/runtime/hono.js",
|
|
27
29
|
"./runtime/rasp": "./dist/runtime/rasp.js",
|
|
28
30
|
"./runtime/register": "./dist/runtime/register.js",
|
|
29
31
|
"./runtime/auto": "./dist/runtime/auto.js",
|
|
@@ -32,7 +34,11 @@
|
|
|
32
34
|
},
|
|
33
35
|
"peerDependencies": {
|
|
34
36
|
"express": "*",
|
|
35
|
-
"fastify": "*"
|
|
37
|
+
"fastify": "*",
|
|
38
|
+
"koa": "*",
|
|
39
|
+
"hono": "*",
|
|
40
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
41
|
+
"zod": "^4.6.7"
|
|
36
42
|
},
|
|
37
43
|
"peerDependenciesMeta": {
|
|
38
44
|
"express": {
|
|
@@ -40,6 +46,18 @@
|
|
|
40
46
|
},
|
|
41
47
|
"fastify": {
|
|
42
48
|
"optional": true
|
|
49
|
+
},
|
|
50
|
+
"koa": {
|
|
51
|
+
"optional": true
|
|
52
|
+
},
|
|
53
|
+
"hono": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@modelcontextprotocol/sdk": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"zod": {
|
|
60
|
+
"optional": true
|
|
43
61
|
}
|
|
44
62
|
},
|
|
45
63
|
"files": [
|
|
@@ -64,18 +82,18 @@
|
|
|
64
82
|
"test:e2e": "vitest run test/e2e",
|
|
65
83
|
"test:live-ai": "npm run build && node scripts/live-ai.mjs",
|
|
66
84
|
"bench": "node scripts/bench.mjs",
|
|
85
|
+
"bench:public": "node scripts/bench-public.mjs",
|
|
67
86
|
"verify": "node scripts/verify.mjs",
|
|
68
87
|
"verify:phase": "node scripts/verify.mjs --phase"
|
|
69
88
|
},
|
|
70
89
|
"dependencies": {
|
|
71
|
-
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
72
90
|
"@typescript-eslint/typescript-estree": "^8.71.2",
|
|
73
91
|
"commander": "^12.1.0",
|
|
74
92
|
"ignore": "^5.3.2",
|
|
75
|
-
"picocolors": "^1.1.1"
|
|
76
|
-
"zod": "^4.6.7"
|
|
93
|
+
"picocolors": "^1.1.1"
|
|
77
94
|
},
|
|
78
95
|
"devDependencies": {
|
|
96
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
79
97
|
"@eslint/js": "^9.14.0",
|
|
80
98
|
"@types/node": "^22.9.0",
|
|
81
99
|
"@vitest/coverage-v8": "^2.1.4",
|
|
@@ -85,6 +103,7 @@
|
|
|
85
103
|
"tsup": "^8.3.5",
|
|
86
104
|
"typescript": "^5.6.3",
|
|
87
105
|
"typescript-eslint": "^8.13.0",
|
|
88
|
-
"vitest": "^2.1.4"
|
|
106
|
+
"vitest": "^2.1.4",
|
|
107
|
+
"zod": "^4.6.7"
|
|
89
108
|
}
|
|
90
109
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# CONFIG-DIRLIST-001
|
|
2
|
+
|
|
3
|
+
**`serveIndex()` enables HTTP directory listing.** CWE-548.
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Remove `serve-index`, or put the route behind authentication. Do not list ftp, logs, or key directories.
|
|
8
|
+
|
|
9
|
+
See `SECURITY_SCANNER_SPEC.md` §4 and the fixtures under `test/fixtures/`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# CONFIG-TRUSTPROXY-001
|
|
2
|
+
|
|
3
|
+
**`trust proxy` set to `true` while IP-based rate limiting is used (spoofable `X-Forwarded-For`).** CWE-290.
|
|
4
|
+
|
|
5
|
+
Experimental (`FULLSHIELD_EXPERIMENTAL_CLASSES=1`).
|
|
6
|
+
|
|
7
|
+
## Remediation
|
|
8
|
+
|
|
9
|
+
Set `trust proxy` to a hop count or subnet, not `true`. Key rate limits on a verified identity, not the raw client IP.
|
|
10
|
+
|
|
11
|
+
See `SECURITY_SCANNER_SPEC.md` §24 Phase 29.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# DLP-CARD-001
|
|
2
|
+
|
|
3
|
+
**Outgoing response contains a Luhn-valid card number.** CWE-359. Experimental (`FULLSHIELD_EXPERIMENTAL_DLP=1`).
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Never return full PANs. Store tokens; show only the last four digits if needed.
|
|
8
|
+
|
|
9
|
+
See `SECURITY_SCANNER_SPEC.md` §24 Phase 32 and the fixtures under `test/fixtures/dlp/`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# DLP-INTERNAL-001
|
|
2
|
+
|
|
3
|
+
**Outgoing response contains an internal IP or hostname.** CWE-200. Experimental (`FULLSHIELD_EXPERIMENTAL_DLP=1`).
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Do not expose RFC1918 / loopback / `*.internal` addresses in client responses.
|
|
8
|
+
|
|
9
|
+
See `SECURITY_SCANNER_SPEC.md` §24 Phase 32 and the fixtures under `test/fixtures/dlp/`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# DLP-PII-001
|
|
2
|
+
|
|
3
|
+
**Outgoing response contains bulk emails on a route that normally returns one.** CWE-359. Experimental (`FULLSHIELD_EXPERIMENTAL_DLP=1`).
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Scope list/export endpoints. Do not dump address books from a single-user route.
|
|
8
|
+
|
|
9
|
+
See `SECURITY_SCANNER_SPEC.md` §24 Phase 32 and the fixtures under `test/fixtures/dlp/`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# DLP-SECRET-001
|
|
2
|
+
|
|
3
|
+
**Outgoing response contains a secret pattern** (AWS, Stripe, GitHub, Slack, Google, AI, PEM, DB URL). CWE-200. Experimental (`FULLSHIELD_EXPERIMENTAL_DLP=1`).
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Do not send credentials in API responses. Rotate anything that leaked. Prefer `shieldGuard({ dlp: { mode: 'monitor' } })` until the benign corpus is measured.
|
|
8
|
+
|
|
9
|
+
See `SECURITY_SCANNER_SPEC.md` §24 Phase 32 and the fixtures under `test/fixtures/dlp/`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# DLP-STACK-001
|
|
2
|
+
|
|
3
|
+
**Outgoing response contains a stack trace.** CWE-209. Experimental (`FULLSHIELD_EXPERIMENTAL_DLP=1`).
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Log stacks server-side. Return a generic error body to clients.
|
|
8
|
+
|
|
9
|
+
See `SECURITY_SCANNER_SPEC.md` §24 Phase 32 and the fixtures under `test/fixtures/dlp/`.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# HOST-SSH-001
|
|
2
|
+
|
|
3
|
+
**OpenSSH package version is in the CVE-2024-6387 (regreSSHion) range.** CWE-364.
|
|
4
|
+
|
|
5
|
+
Uses the distro package version (`dpkg-query` / `rpm` / `apk`), not `ssh -V`. Distro OSV advisories win so backported fixes are not flagged. Upstream fallback: 8.5p1 ≤ v < 9.8p1, or v < 4.4p1.
|
|
6
|
+
|
|
7
|
+
Severity is **critical** only when the version is vulnerable, `LoginGraceTime` is not 0, and sshd is listening on a non-loopback address.
|
|
8
|
+
|
|
9
|
+
## Remediation
|
|
10
|
+
|
|
11
|
+
Upgrade `openssh-server` via the distro package manager. Temporary mitigation: `LoginGraceTime 0` (enables a connection-exhaustion DoS). FullShield does not auto-patch or stop OS/SSH exploits.
|
|
12
|
+
|
|
13
|
+
Experimental: `fullshield host --experimental-host`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# HOST-SSH-002
|
|
2
|
+
|
|
3
|
+
**Vulnerable OpenSSH without the `LoginGraceTime 0` mitigation.** CWE-693.
|
|
4
|
+
|
|
5
|
+
Emitted only when HOST-SSH-001 applies and effective sshd config (`sshd -T`, or `/etc/ssh/sshd_config` plus `Include` files if `sshd -T` is not permitted) does not have `logingracetime 0`.
|
|
6
|
+
|
|
7
|
+
## Remediation
|
|
8
|
+
|
|
9
|
+
Set `LoginGraceTime 0` and reload sshd as a temporary mitigation. Note: this enables a connection-exhaustion DoS. Prefer upgrading `openssh-server`. FullShield does not change sshd configuration.
|
|
10
|
+
|
|
11
|
+
Experimental: `fullshield host --experimental-host`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# HOST-SSH-003
|
|
2
|
+
|
|
3
|
+
**Possible SSH authentication-timeout exploitation attempts.** CWE-307.
|
|
4
|
+
|
|
5
|
+
Counts `Timeout before authentication` lines per source IP in the last 24h from `journalctl -u ssh*` or `/var/log/auth.log` when those files are readable **without sudo**. Never a confirmed attack.
|
|
6
|
+
|
|
7
|
+
## Remediation
|
|
8
|
+
|
|
9
|
+
Use fail2ban or CrowdSec to block abusive sources. FullShield does not install firewall rules or block IPs.
|
|
10
|
+
|
|
11
|
+
Experimental: `fullshield host --experimental-host`.
|
package/rules/MCP-001.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# MCP-001
|
|
2
|
+
|
|
3
|
+
**Secret inlined in an MCP client config env block.** CWE-798. Experimental (`FULLSHIELD_EXPERIMENTAL_MCPCFG=1`). High.
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Replace the value with an environment-variable reference (`${GITHUB_TOKEN}`) and rotate the exposed credential. Removing it from the file is not enough: it remains in git history.
|
package/rules/MCP-002.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# MCP-002
|
|
2
|
+
|
|
3
|
+
**MCP server launched via unpinned `npx <pkg>` or `@latest`.** CWE-494. Experimental (`FULLSHIELD_EXPERIMENTAL_MCPCFG=1`). Medium.
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Pin the package to an exact version (`@scope/pkg@1.2.3`). Do not use `@latest` or a bare package name.
|
package/rules/MCP-003.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# MCP-003
|
|
2
|
+
|
|
3
|
+
**MCP server exposes shell/exec or unrestricted filesystem-write tools without an allowlist.** CWE-250. Experimental (`FULLSHIELD_EXPERIMENTAL_MCPCFG=1`). Medium.
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Restrict filesystem roots to project directories. Do not register shell/exec/write tools unless an allowlist (and a confirm step) is enforced. See `guardTools`.
|
package/rules/MCP-004.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# MCP-004
|
|
2
|
+
|
|
3
|
+
**MCP tool description contains instruction-injection text, hidden markup, or zero-width characters.** CWE-74. Experimental (`FULLSHIELD_EXPERIMENTAL_MCPCFG=1`). High.
|
|
4
|
+
|
|
5
|
+
## Remediation
|
|
6
|
+
|
|
7
|
+
Keep descriptions literal and user-facing. Strip zero-width / bidi-override characters and any text aimed at the model ("ignore previous", hidden HTML comments).
|