@vibefoundry/vibe-shield 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +571 -3
- package/SECURITY.md +110 -0
- package/SKILL.md +124 -0
- package/dist/bin/vibe-shield.d.ts +59 -0
- package/dist/bin/vibe-shield.js +541 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +108 -0
- package/dist/src/init/scaffold.d.ts +122 -0
- package/dist/src/init/scaffold.js +573 -0
- package/dist/src/mcp/server.d.ts +127 -0
- package/dist/src/mcp/server.js +479 -0
- package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
- package/dist/src/mcp-guard/skill-auditor.js +1129 -0
- package/dist/src/reporter/console-reporter.d.ts +58 -0
- package/dist/src/reporter/console-reporter.js +254 -0
- package/dist/src/reporter/formatters.d.ts +38 -0
- package/dist/src/reporter/formatters.js +262 -0
- package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
- package/dist/src/reporter/patch-prompt-writer.js +238 -0
- package/dist/src/scanner/context.d.ts +51 -0
- package/dist/src/scanner/context.js +568 -0
- package/dist/src/scanner/engine.d.ts +130 -0
- package/dist/src/scanner/engine.js +722 -0
- package/dist/src/scanner/gitignore.d.ts +49 -0
- package/dist/src/scanner/gitignore.js +195 -0
- package/dist/src/scanner/lexicon.d.ts +56 -0
- package/dist/src/scanner/lexicon.js +357 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
- package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
- package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
- package/dist/src/scanner/rules/client-exposure.js +410 -0
- package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
- package/dist/src/scanner/rules/database-exposure.js +626 -0
- package/dist/src/scanner/rules/helpers.d.ts +31 -0
- package/dist/src/scanner/rules/helpers.js +85 -0
- package/dist/src/scanner/rules/index.d.ts +30 -0
- package/dist/src/scanner/rules/index.js +95 -0
- package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
- package/dist/src/scanner/rules/secret-entropy.js +338 -0
- package/dist/src/scanner/rules/shared.d.ts +100 -0
- package/dist/src/scanner/rules/shared.js +161 -0
- package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
- package/dist/src/scanner/rules/unguarded-routes.js +526 -0
- package/dist/src/scanner/text.d.ts +90 -0
- package/dist/src/scanner/text.js +472 -0
- package/dist/src/scanner/types.d.ts +340 -0
- package/dist/src/scanner/types.js +195 -0
- package/dist/src/secrets/patterns.d.ts +63 -0
- package/dist/src/secrets/patterns.js +392 -0
- package/package.json +94 -4
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: rule authoring helpers
|
|
4
|
+
* -------------------------------------
|
|
5
|
+
* `defineRule` removes all boilerplate: a rule declares *what it matches* and
|
|
6
|
+
* the helper derives scope filtering (`extensions`, `languages`, path regexes,
|
|
7
|
+
* skipped file kinds) so the engine can cheaply decide whether to run it.
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.message = exports.contains = exports.snippetAt = void 0;
|
|
11
|
+
exports.inScope = inScope;
|
|
12
|
+
exports.defineRule = defineRule;
|
|
13
|
+
exports.regexHits = regexHits;
|
|
14
|
+
exports.bodyFrom = bodyFrom;
|
|
15
|
+
exports.fileHit = fileHit;
|
|
16
|
+
const text_1 = require("../text");
|
|
17
|
+
/** Does this file match the declarative scope of a rule? */
|
|
18
|
+
function inScope(def, ctx) {
|
|
19
|
+
if (def.skipFlags?.some((flag) => ctx.flags[flag] === true))
|
|
20
|
+
return false;
|
|
21
|
+
if (def.languages?.length && !def.languages.includes(ctx.language))
|
|
22
|
+
return false;
|
|
23
|
+
if (def.extensions?.length) {
|
|
24
|
+
const extension = ctx.extension || ctx.fileName.toLowerCase();
|
|
25
|
+
if (!def.extensions.includes(extension))
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
if (def.pathPatterns?.length && !def.pathPatterns.some((re) => re.test(ctx.relativePath)))
|
|
29
|
+
return false;
|
|
30
|
+
if (def.excludePathPatterns?.some((re) => re.test(ctx.relativePath)))
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
/** Create a fully-formed rule object. */
|
|
35
|
+
function defineRule(def) {
|
|
36
|
+
const custom = def.appliesTo;
|
|
37
|
+
const rule = {
|
|
38
|
+
...def,
|
|
39
|
+
appliesTo: (ctx) => (custom ? custom(ctx) && inScope(def, ctx) : inScope(def, ctx)),
|
|
40
|
+
};
|
|
41
|
+
return rule;
|
|
42
|
+
}
|
|
43
|
+
/** Every match of `regex` inside `source` as rule hits. */
|
|
44
|
+
function regexHits(source, regex, map) {
|
|
45
|
+
const hits = [];
|
|
46
|
+
const re = new RegExp(regex.source, regex.flags.includes('g') ? regex.flags : `${regex.flags}g`);
|
|
47
|
+
let match;
|
|
48
|
+
let guard = 0;
|
|
49
|
+
while ((match = re.exec(source)) !== null && guard < 20_000) {
|
|
50
|
+
guard += 1;
|
|
51
|
+
if (match[0].length === 0) {
|
|
52
|
+
re.lastIndex += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const extra = map ? map(match) : undefined;
|
|
56
|
+
if (extra === null) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
hits.push({ offset: match.index, matched: match[0], ...(extra ?? {}) });
|
|
60
|
+
if (re.lastIndex === match.index)
|
|
61
|
+
re.lastIndex += 1;
|
|
62
|
+
}
|
|
63
|
+
return hits;
|
|
64
|
+
}
|
|
65
|
+
/** The `{...}` (or `(...)`) body that starts at/after `offset`. */
|
|
66
|
+
function bodyFrom(source, offset, open = '{') {
|
|
67
|
+
const close = open === '{' ? '}' : ')';
|
|
68
|
+
return (0, text_1.balancedBlock)(source, (0, text_1.nextBrace)(source, offset), open, close);
|
|
69
|
+
}
|
|
70
|
+
/** Convenience: read one source line, trimmed for reporting. */
|
|
71
|
+
const snippetAt = (ctx, line) => (0, text_1.trimSnippet)(ctx.lineText(line));
|
|
72
|
+
exports.snippetAt = snippetAt;
|
|
73
|
+
/** True when `needle` (regex) appears inside `haystack`. */
|
|
74
|
+
const contains = (haystack, needle) => needle.test(haystack);
|
|
75
|
+
exports.contains = contains;
|
|
76
|
+
/** Build a message that always ends with a period and never leaks a secret. */
|
|
77
|
+
const message = (base, detail) => detail ? `${base.replace(/\.$/, '')} — ${detail}` : base;
|
|
78
|
+
exports.message = message;
|
|
79
|
+
/**
|
|
80
|
+
* Some rules need to inspect the *rest of the file* rather than a single
|
|
81
|
+
* match. `fileHit` produces exactly one hit anchored at `line`.
|
|
82
|
+
*/
|
|
83
|
+
function fileHit(line, extra = {}) {
|
|
84
|
+
return { line, ...extra };
|
|
85
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe-shield :: rule registry
|
|
3
|
+
* ----------------------------
|
|
4
|
+
* A stable, ordered catalogue of every rule the scanner ships. The MCP/skill
|
|
5
|
+
* auditor (`src/mcp-guard/skill-auditor.ts`) owns the `VSB-AGENT-*` family and
|
|
6
|
+
* is composed here so the CLI, the SARIF reporter and `--list-rules` all see a
|
|
7
|
+
* single list.
|
|
8
|
+
*/
|
|
9
|
+
import type { Rule, RuleCategory } from '../types';
|
|
10
|
+
export { AI_VULNERABILITY_RULES } from './ai-vulnerabilities';
|
|
11
|
+
export { CLIENT_EXPOSURE_RULES } from './client-exposure';
|
|
12
|
+
export { DATABASE_EXPOSURE_RULES } from './database-exposure';
|
|
13
|
+
export { SECRET_ENTROPY_RULES } from './secret-entropy';
|
|
14
|
+
export { UNGUARDED_ROUTE_RULES } from './unguarded-routes';
|
|
15
|
+
export { defineRule, inScope, regexHits, bodyFrom, snippetAt, contains, message, fileHit } from './helpers';
|
|
16
|
+
/** Every rule that runs during `vibe-shield scan`. */
|
|
17
|
+
export declare const SCAN_RULES: readonly Rule[];
|
|
18
|
+
/** Every rule the tool knows about, including the agent-integrity family. */
|
|
19
|
+
export declare const ALL_RULES: readonly Rule[];
|
|
20
|
+
export declare function getRule(idOrSlug: string): Rule | undefined;
|
|
21
|
+
/** Rules in one category, in declaration order. */
|
|
22
|
+
export declare function rulesByCategory(category: RuleCategory): Rule[];
|
|
23
|
+
/** Filter a rule set by explicit ids/slugs (`--rules VSB-AI-001,VSB-DB-002`). */
|
|
24
|
+
export declare function selectRules(selectors: readonly string[], pool?: readonly Rule[]): Rule[];
|
|
25
|
+
/**
|
|
26
|
+
* Deterministic fingerprint of the rule catalogue. Cached scan results are
|
|
27
|
+
* invalidated when this changes, so a rule edit can never silently reuse an
|
|
28
|
+
* older verdict.
|
|
29
|
+
*/
|
|
30
|
+
export declare const RULES_FINGERPRINT: string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: rule registry
|
|
4
|
+
* ----------------------------
|
|
5
|
+
* A stable, ordered catalogue of every rule the scanner ships. The MCP/skill
|
|
6
|
+
* auditor (`src/mcp-guard/skill-auditor.ts`) owns the `VSB-AGENT-*` family and
|
|
7
|
+
* is composed here so the CLI, the SARIF reporter and `--list-rules` all see a
|
|
8
|
+
* single list.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.RULES_FINGERPRINT = exports.ALL_RULES = exports.SCAN_RULES = exports.fileHit = exports.message = exports.contains = exports.snippetAt = exports.bodyFrom = exports.regexHits = exports.inScope = exports.defineRule = exports.UNGUARDED_ROUTE_RULES = exports.SECRET_ENTROPY_RULES = exports.DATABASE_EXPOSURE_RULES = exports.CLIENT_EXPOSURE_RULES = exports.AI_VULNERABILITY_RULES = void 0;
|
|
12
|
+
exports.getRule = getRule;
|
|
13
|
+
exports.rulesByCategory = rulesByCategory;
|
|
14
|
+
exports.selectRules = selectRules;
|
|
15
|
+
const ai_vulnerabilities_1 = require("./ai-vulnerabilities");
|
|
16
|
+
const client_exposure_1 = require("./client-exposure");
|
|
17
|
+
const database_exposure_1 = require("./database-exposure");
|
|
18
|
+
const secret_entropy_1 = require("./secret-entropy");
|
|
19
|
+
const unguarded_routes_1 = require("./unguarded-routes");
|
|
20
|
+
const skill_auditor_1 = require("../../mcp-guard/skill-auditor");
|
|
21
|
+
var ai_vulnerabilities_2 = require("./ai-vulnerabilities");
|
|
22
|
+
Object.defineProperty(exports, "AI_VULNERABILITY_RULES", { enumerable: true, get: function () { return ai_vulnerabilities_2.AI_VULNERABILITY_RULES; } });
|
|
23
|
+
var client_exposure_2 = require("./client-exposure");
|
|
24
|
+
Object.defineProperty(exports, "CLIENT_EXPOSURE_RULES", { enumerable: true, get: function () { return client_exposure_2.CLIENT_EXPOSURE_RULES; } });
|
|
25
|
+
var database_exposure_2 = require("./database-exposure");
|
|
26
|
+
Object.defineProperty(exports, "DATABASE_EXPOSURE_RULES", { enumerable: true, get: function () { return database_exposure_2.DATABASE_EXPOSURE_RULES; } });
|
|
27
|
+
var secret_entropy_2 = require("./secret-entropy");
|
|
28
|
+
Object.defineProperty(exports, "SECRET_ENTROPY_RULES", { enumerable: true, get: function () { return secret_entropy_2.SECRET_ENTROPY_RULES; } });
|
|
29
|
+
var unguarded_routes_2 = require("./unguarded-routes");
|
|
30
|
+
Object.defineProperty(exports, "UNGUARDED_ROUTE_RULES", { enumerable: true, get: function () { return unguarded_routes_2.UNGUARDED_ROUTE_RULES; } });
|
|
31
|
+
var helpers_1 = require("./helpers");
|
|
32
|
+
Object.defineProperty(exports, "defineRule", { enumerable: true, get: function () { return helpers_1.defineRule; } });
|
|
33
|
+
Object.defineProperty(exports, "inScope", { enumerable: true, get: function () { return helpers_1.inScope; } });
|
|
34
|
+
Object.defineProperty(exports, "regexHits", { enumerable: true, get: function () { return helpers_1.regexHits; } });
|
|
35
|
+
Object.defineProperty(exports, "bodyFrom", { enumerable: true, get: function () { return helpers_1.bodyFrom; } });
|
|
36
|
+
Object.defineProperty(exports, "snippetAt", { enumerable: true, get: function () { return helpers_1.snippetAt; } });
|
|
37
|
+
Object.defineProperty(exports, "contains", { enumerable: true, get: function () { return helpers_1.contains; } });
|
|
38
|
+
Object.defineProperty(exports, "message", { enumerable: true, get: function () { return helpers_1.message; } });
|
|
39
|
+
Object.defineProperty(exports, "fileHit", { enumerable: true, get: function () { return helpers_1.fileHit; } });
|
|
40
|
+
/** Every rule that runs during `vibe-shield scan`. */
|
|
41
|
+
exports.SCAN_RULES = [
|
|
42
|
+
...client_exposure_1.CLIENT_EXPOSURE_RULES,
|
|
43
|
+
...unguarded_routes_1.UNGUARDED_ROUTE_RULES,
|
|
44
|
+
...database_exposure_1.DATABASE_EXPOSURE_RULES,
|
|
45
|
+
...ai_vulnerabilities_1.AI_VULNERABILITY_RULES,
|
|
46
|
+
...secret_entropy_1.SECRET_ENTROPY_RULES,
|
|
47
|
+
];
|
|
48
|
+
/** Every rule the tool knows about, including the agent-integrity family. */
|
|
49
|
+
exports.ALL_RULES = [...exports.SCAN_RULES, ...skill_auditor_1.AGENT_RULES];
|
|
50
|
+
/** Lookup by exact rule id (case-insensitive). */
|
|
51
|
+
const BY_ID = new Map(exports.ALL_RULES.map((rule) => [rule.id.toUpperCase(), rule]));
|
|
52
|
+
/** Lookup by slug (case-insensitive), for `explain <slug>` convenience. */
|
|
53
|
+
const BY_SLUG = new Map(exports.ALL_RULES.map((rule) => [rule.slug.toLowerCase(), rule]));
|
|
54
|
+
function getRule(idOrSlug) {
|
|
55
|
+
return BY_ID.get(idOrSlug.trim().toUpperCase()) ?? BY_SLUG.get(idOrSlug.trim().toLowerCase());
|
|
56
|
+
}
|
|
57
|
+
/** Rules in one category, in declaration order. */
|
|
58
|
+
function rulesByCategory(category) {
|
|
59
|
+
return exports.ALL_RULES.filter((rule) => rule.category === category);
|
|
60
|
+
}
|
|
61
|
+
/** Filter a rule set by explicit ids/slugs (`--rules VSB-AI-001,VSB-DB-002`). */
|
|
62
|
+
function selectRules(selectors, pool = exports.ALL_RULES) {
|
|
63
|
+
if (!selectors.length)
|
|
64
|
+
return [...pool];
|
|
65
|
+
const wanted = new Set();
|
|
66
|
+
for (const selector of selectors) {
|
|
67
|
+
const rule = getRule(selector);
|
|
68
|
+
if (rule) {
|
|
69
|
+
wanted.add(rule.id);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Allow prefix / glob-ish selectors: `VSB-DB-*`, `VSB-AI`.
|
|
73
|
+
const pattern = selector.trim().toUpperCase().replace(/[.+]/g, (c) => `\\${c}`).replace(/\*/g, '');
|
|
74
|
+
const regex = new RegExp(`^${pattern}`, 'i');
|
|
75
|
+
for (const candidate of pool) {
|
|
76
|
+
if (regex.test(candidate.id))
|
|
77
|
+
wanted.add(candidate.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return pool.filter((rule) => wanted.has(rule.id));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Deterministic fingerprint of the rule catalogue. Cached scan results are
|
|
84
|
+
* invalidated when this changes, so a rule edit can never silently reuse an
|
|
85
|
+
* older verdict.
|
|
86
|
+
*/
|
|
87
|
+
exports.RULES_FINGERPRINT = (() => {
|
|
88
|
+
const payload = exports.ALL_RULES.map((rule) => `${rule.id}:${rule.severity}:${rule.title}`).join('|');
|
|
89
|
+
let hash = 0x811c9dc5;
|
|
90
|
+
for (let i = 0; i < payload.length; i += 1) {
|
|
91
|
+
hash ^= payload.charCodeAt(i);
|
|
92
|
+
hash = Math.imul(hash, 0x01000193) >>> 0;
|
|
93
|
+
}
|
|
94
|
+
return `${exports.ALL_RULES.length}-${hash.toString(16)}`;
|
|
95
|
+
})();
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vibe-shield :: secret-entropy rules
|
|
3
|
+
* -----------------------------------
|
|
4
|
+
* `src/secrets/patterns.ts` knows ~40 vendor signature formats (Stripe, OpenAI,
|
|
5
|
+
* Anthropic, AWS, GitHub, Supabase, Slack...) plus a generic high-entropy
|
|
6
|
+
* assignment heuristic. This module turns those matches into findings with the
|
|
7
|
+
* right severity for *where* the secret sits:
|
|
8
|
+
*
|
|
9
|
+
* - a live key in a server file is a critical hardcoded credential,
|
|
10
|
+
* - the same key in a browser-reachable file is an immediate data breach,
|
|
11
|
+
* - the same key inside a committed `.env` is a repository leak.
|
|
12
|
+
*
|
|
13
|
+
* Splitting rules by destination (instead of reporting one match three ways) is
|
|
14
|
+
* what keeps the scorecard honest and the patch prompt actionable.
|
|
15
|
+
*/
|
|
16
|
+
import type { Rule } from '../types';
|
|
17
|
+
export declare const SECRET_ENTROPY_RULES: readonly Rule[];
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* vibe-shield :: secret-entropy rules
|
|
4
|
+
* -----------------------------------
|
|
5
|
+
* `src/secrets/patterns.ts` knows ~40 vendor signature formats (Stripe, OpenAI,
|
|
6
|
+
* Anthropic, AWS, GitHub, Supabase, Slack...) plus a generic high-entropy
|
|
7
|
+
* assignment heuristic. This module turns those matches into findings with the
|
|
8
|
+
* right severity for *where* the secret sits:
|
|
9
|
+
*
|
|
10
|
+
* - a live key in a server file is a critical hardcoded credential,
|
|
11
|
+
* - the same key in a browser-reachable file is an immediate data breach,
|
|
12
|
+
* - the same key inside a committed `.env` is a repository leak.
|
|
13
|
+
*
|
|
14
|
+
* Splitting rules by destination (instead of reporting one match three ways) is
|
|
15
|
+
* what keeps the scorecard honest and the patch prompt actionable.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.SECRET_ENTROPY_RULES = void 0;
|
|
19
|
+
const patterns_1 = require("../../secrets/patterns");
|
|
20
|
+
const text_1 = require("../text");
|
|
21
|
+
const helpers_1 = require("./helpers");
|
|
22
|
+
const shared_1 = require("./shared");
|
|
23
|
+
const CLIENT_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs', 'vue', 'svelte', 'html', 'mdx'];
|
|
24
|
+
/** Destinations that are not shipped to users: docs, tests, fixtures, examples. */
|
|
25
|
+
const isNonShipping = (ctx) => ctx.flags.isTestFile || ctx.flags.isDocumentation || ctx.flags.isLockfile || ctx.flags.isGenerated || ctx.flags.isEnvFile;
|
|
26
|
+
/** Do not re-report secrets inside the detector catalogue or its fixtures. */
|
|
27
|
+
const isDetectorMaterial = (ctx) => ctx.language === 'markdown' ||
|
|
28
|
+
/(?:^|\/)(?:tests?|fixtures?|__tests__)\//.test(ctx.relativePath) ||
|
|
29
|
+
/(?:^|\/)src\/(?:secrets|mcp-guard\/skill-auditor)/.test(ctx.relativePath);
|
|
30
|
+
/** All secrets in the file's comment-masked code (so commented samples are ignored). */
|
|
31
|
+
const secretHits = (ctx, options = {}) => (0, patterns_1.findSecrets)(ctx.code, options);
|
|
32
|
+
const toHit = (match, extra = {}) => ({
|
|
33
|
+
offset: match.offset,
|
|
34
|
+
matched: match.display,
|
|
35
|
+
severity: extra.severity ?? match.severity,
|
|
36
|
+
confidence: extra.confidence ?? 'high',
|
|
37
|
+
message: extra.message ?? match.note,
|
|
38
|
+
data: {
|
|
39
|
+
patternId: match.patternId,
|
|
40
|
+
vendor: match.vendor,
|
|
41
|
+
family: match.family,
|
|
42
|
+
entropy: match.entropy,
|
|
43
|
+
...(extra.data ?? {}),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
/** VSB-SECRET-001 - vendor credential in a non-shipping (server) file. */
|
|
47
|
+
const vendorSecret = (0, helpers_1.defineRule)({
|
|
48
|
+
id: 'VSB-SECRET-001',
|
|
49
|
+
slug: 'hardcoded-vendor-credential',
|
|
50
|
+
title: 'Hardcoded third-party credential in source code',
|
|
51
|
+
category: 'secret-entropy',
|
|
52
|
+
severity: 'high',
|
|
53
|
+
confidence: 'high',
|
|
54
|
+
cwe: shared_1.CWES.hardcodedCredentials,
|
|
55
|
+
description: 'A live-looking provider key is committed as a literal. Source code is copied, forked, pasted into issues and archived in CI logs — treat the key as public the moment it lands in a commit.',
|
|
56
|
+
insecureExample: 'const stripe = new Stripe("sk_live_51H8x…");',
|
|
57
|
+
secureExample: 'const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);',
|
|
58
|
+
remediation: [
|
|
59
|
+
'Rotate the credential at the provider *first* — assume it is already compromised.',
|
|
60
|
+
'Move the value into an environment variable (`.env.local`, your host\'s secret store) and read it via `process.env`.',
|
|
61
|
+
'Add a prefixed placeholder to `.env.example` so the next developer knows the variable is required.',
|
|
62
|
+
'Enable secret scanning / push protection on the repository so the next commit is blocked, and purge the value from git history.',
|
|
63
|
+
],
|
|
64
|
+
references: [
|
|
65
|
+
'https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html',
|
|
66
|
+
'https://cwe.mitre.org/data/definitions/798.html',
|
|
67
|
+
],
|
|
68
|
+
extensions: [...CLIENT_EXTENSIONS, 'py', 'rb', 'php', 'go', 'sh', 'yml', 'yaml', 'json', 'toml', 'tf', 'env'],
|
|
69
|
+
skipFlags: ['isLockfile', 'isGenerated'],
|
|
70
|
+
appliesTo: (ctx) => !isNonShipping(ctx) && !isDetectorMaterial(ctx),
|
|
71
|
+
contentHints: [/sk_|pk_live|AKIA|ghp_|xox[baprs]-|AIza|sk-ant-|eyJhbGciOi|whsec_|SG\./],
|
|
72
|
+
detect(ctx) {
|
|
73
|
+
return secretHits(ctx, { skipGeneric: true })
|
|
74
|
+
.filter((match) => match.family !== 'private-key' && match.family !== 'jwt')
|
|
75
|
+
.filter(() => !(0, shared_1.isBrowserFile)(ctx))
|
|
76
|
+
.map((match) => toHit(match));
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
/** VSB-SECRET-002 - generic high-entropy assignment. */
|
|
80
|
+
const genericSecret = (0, helpers_1.defineRule)({
|
|
81
|
+
id: 'VSB-SECRET-002',
|
|
82
|
+
slug: 'high-entropy-secret',
|
|
83
|
+
title: 'High-entropy value assigned to a credential-shaped name',
|
|
84
|
+
category: 'secret-entropy',
|
|
85
|
+
severity: 'medium',
|
|
86
|
+
confidence: 'medium',
|
|
87
|
+
cwe: shared_1.CWES.hardcodedCredentials,
|
|
88
|
+
description: 'The variable name says "secret" and the value has the entropy of a real key. There is no vendor signature to match — which is exactly why this class survives a naïve regex review.',
|
|
89
|
+
insecureExample: 'const SIGNING_SECRET = "9f2b8c4e7a1d3f5b8c6e9a2d4f7b1c3e";',
|
|
90
|
+
secureExample: 'const SIGNING_SECRET = process.env.SIGNING_SECRET!;',
|
|
91
|
+
remediation: [
|
|
92
|
+
'Move the value to an environment variable and rotate it at the provider.',
|
|
93
|
+
'If the value is genuinely a constant (a salt for a public hash, a test fixture), move it to the test/fixture tree where it is not scanned.',
|
|
94
|
+
'Prefer asymmetric signing so the verifying side never needs the secret at all.',
|
|
95
|
+
],
|
|
96
|
+
references: ['https://cwe.mitre.org/data/definitions/798.html'],
|
|
97
|
+
extensions: [...CLIENT_EXTENSIONS, 'py', 'rb', 'php', 'go', 'sh', 'yml', 'yaml', 'json', 'toml', 'env'],
|
|
98
|
+
skipFlags: ['isLockfile', 'isGenerated'],
|
|
99
|
+
appliesTo: (ctx) => !isNonShipping(ctx) && !isDetectorMaterial(ctx),
|
|
100
|
+
contentHints: [/secret|token|key|password|credential/i],
|
|
101
|
+
detect(ctx) {
|
|
102
|
+
const vendorIds = new Set(secretHits(ctx, { skipGeneric: true }).map((match) => `${match.offset}:${match.length}`));
|
|
103
|
+
return secretHits(ctx)
|
|
104
|
+
.filter((match) => match.patternId === 'generic')
|
|
105
|
+
.filter((match) => !vendorIds.has(`${match.offset}:${match.length}`))
|
|
106
|
+
.filter((match) => !/(?:example|sample|placeholder)/i.test(match.vendor))
|
|
107
|
+
.map((match) => {
|
|
108
|
+
const severity = match.entropy >= 4.2 && match.value.length >= 32 ? 'high' : 'medium';
|
|
109
|
+
return toHit(match, {
|
|
110
|
+
severity,
|
|
111
|
+
confidence: match.entropy >= 4 ? 'high' : 'medium',
|
|
112
|
+
message: `\`${match.vendor}\` is assigned a literal with Shannon entropy ${match.entropy.toFixed(2)} — it looks like a real credential.`,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
/** VSB-SECRET-003 - private key material. */
|
|
118
|
+
const privateKeyMaterial = (0, helpers_1.defineRule)({
|
|
119
|
+
id: 'VSB-SECRET-003',
|
|
120
|
+
slug: 'private-key-material',
|
|
121
|
+
title: 'Private key committed to the repository',
|
|
122
|
+
category: 'secret-entropy',
|
|
123
|
+
severity: 'critical',
|
|
124
|
+
confidence: 'high',
|
|
125
|
+
cwe: shared_1.CWES.privateKey,
|
|
126
|
+
description: 'A PEM private key in the tree lets anyone holding the repository impersonate your service: sign JWTs, decrypt payloads, push to registries or SSH into hosts. Rotating is not optional.',
|
|
127
|
+
insecureExample: '-----BEGIN RSA PRIVATE KEY-----\nMIIEow…',
|
|
128
|
+
secureExample: 'const key = await fs.readFile(process.env.JWT_PRIVATE_KEY_PATH!, "utf8");',
|
|
129
|
+
remediation: [
|
|
130
|
+
'Treat the key as compromised: generate a new pair and revoke the old one with the relying party.',
|
|
131
|
+
'Store private keys in a secret manager / KMS; load them at runtime, never from the repo.',
|
|
132
|
+
'For JWT signing prefer a KMS-backed signer or short-lived tokens with a rotating key id (`kid`).',
|
|
133
|
+
'Purge the key from git history (`git filter-repo`) and add `*.pem`, `*.key`, `id_rsa` to `.gitignore`.',
|
|
134
|
+
],
|
|
135
|
+
references: [
|
|
136
|
+
'https://cwe.mitre.org/data/definitions/321.html',
|
|
137
|
+
'https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository',
|
|
138
|
+
],
|
|
139
|
+
extensions: ['ts', 'tsx', 'js', 'jsx', 'py', 'rb', 'php', 'go', 'sh', 'yml', 'yaml', 'json', 'env', 'pem', 'key', 'crt', 'txt'],
|
|
140
|
+
skipFlags: ['isLockfile', 'isGenerated'],
|
|
141
|
+
appliesTo: (ctx) => !isDetectorMaterial(ctx) && !ctx.flags.isDocumentation,
|
|
142
|
+
contentHints: [/-----BEGIN/],
|
|
143
|
+
detect(ctx) {
|
|
144
|
+
return secretHits(ctx, { skipGeneric: true })
|
|
145
|
+
.filter((match) => match.family === 'private-key' || /-----BEGIN[^-]+PRIVATE KEY(?: BLOCK)?-----/.test(match.value))
|
|
146
|
+
.map((match) => toHit(match, { severity: 'critical', confidence: 'high', message: match.note }));
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
/** VSB-SECRET-004 - hardcoded JWT with a privileged role. */
|
|
150
|
+
const privilegedJwt = (0, helpers_1.defineRule)({
|
|
151
|
+
id: 'VSB-SECRET-004',
|
|
152
|
+
slug: 'hardcoded-privileged-jwt',
|
|
153
|
+
title: 'Hardcoded JWT grants a privileged role',
|
|
154
|
+
category: 'secret-entropy',
|
|
155
|
+
severity: 'critical',
|
|
156
|
+
confidence: 'high',
|
|
157
|
+
cwe: shared_1.CWES.privilegeEscalation,
|
|
158
|
+
description: 'A literal JWT whose payload carries `role: service_role` / `admin` / `superuser` is a skeleton key. Supabase service-role tokens bypass RLS; admin JWTs bypass your authorisation layer at the database.',
|
|
159
|
+
insecureExample: 'const key = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIn0.…";',
|
|
160
|
+
secureExample: 'const key = process.env.SUPABASE_SERVICE_ROLE_KEY!; // server-only module',
|
|
161
|
+
remediation: [
|
|
162
|
+
'Rotate the JWT secret / service-role key immediately — this token is a full-access credential.',
|
|
163
|
+
'Load privileged tokens from the environment in a module marked `import "server-only";`.',
|
|
164
|
+
'Scope the token: use the anon key plus RLS for anything user-facing, and short-lived user tokens elsewhere.',
|
|
165
|
+
],
|
|
166
|
+
references: [
|
|
167
|
+
'https://supabase.com/docs/guides/api/api-keys',
|
|
168
|
+
'https://cwe.mitre.org/data/definitions/250.html',
|
|
169
|
+
],
|
|
170
|
+
extensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'yml', 'yaml', 'env', 'sh', 'py'],
|
|
171
|
+
skipFlags: ['isLockfile', 'isGenerated'],
|
|
172
|
+
appliesTo: (ctx) => !isDetectorMaterial(ctx) && !ctx.flags.isDocumentation,
|
|
173
|
+
contentHints: [/eyJ/],
|
|
174
|
+
detect(ctx) {
|
|
175
|
+
const hits = [];
|
|
176
|
+
for (const match of ctx.code.matchAll(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g)) {
|
|
177
|
+
if (!(0, patterns_1.decodeJwtPayload)(match[0]))
|
|
178
|
+
continue;
|
|
179
|
+
const { privileged, role: detectedRole } = (0, patterns_1.jwtHasPrivilegedRole)(match[0]);
|
|
180
|
+
if (!privileged)
|
|
181
|
+
continue;
|
|
182
|
+
const role = detectedRole ?? 'privileged';
|
|
183
|
+
hits.push({
|
|
184
|
+
offset: match.index,
|
|
185
|
+
matched: `${match[0].slice(0, 12)}…${match[0].slice(-6)}`,
|
|
186
|
+
severity: 'critical',
|
|
187
|
+
confidence: 'high',
|
|
188
|
+
message: `Hardcoded JWT carries the \`${role}\` role, which bypasses row-level security and authorisation checks.`,
|
|
189
|
+
data: { role, patternId: 'jwt-privileged' },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
return hits;
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
/** VSB-SECRET-005 - vendor credential in browser-reachable code. */
|
|
196
|
+
const clientVendorSecret = (0, helpers_1.defineRule)({
|
|
197
|
+
id: 'VSB-SECRET-005',
|
|
198
|
+
slug: 'credential-in-client-bundle',
|
|
199
|
+
title: 'Live credential inside browser-reachable code',
|
|
200
|
+
category: 'secret-entropy',
|
|
201
|
+
severity: 'critical',
|
|
202
|
+
confidence: 'high',
|
|
203
|
+
cwe: shared_1.CWES.exposureOfSecrets,
|
|
204
|
+
description: 'This file is part of the client graph, so the credential is in the JavaScript bundle. It is not a future risk: open devtools on the deployed site and it is there in plain text.',
|
|
205
|
+
insecureExample: 'const stripe = new Stripe("sk_live_51H8x…"); // in a component',
|
|
206
|
+
secureExample: 'await fetch("/api/checkout", { method: "POST" });',
|
|
207
|
+
remediation: [
|
|
208
|
+
'Remove the key from the client bundle and rotate it now.',
|
|
209
|
+
'Proxy the privileged call through a server route that authenticates the user and enforces quotas.',
|
|
210
|
+
'If the SDK must run in the browser, use the publishable/anon key and rely on RLS for data authorisation.',
|
|
211
|
+
],
|
|
212
|
+
references: [
|
|
213
|
+
'https://nextjs.org/docs/app/building-your-application/configuring/environment-variables',
|
|
214
|
+
'https://cwe.mitre.org/data/definitions/200.html',
|
|
215
|
+
],
|
|
216
|
+
extensions: CLIENT_EXTENSIONS,
|
|
217
|
+
skipFlags: ['isLockfile', 'isGenerated'],
|
|
218
|
+
appliesTo: (ctx) => (0, shared_1.isBrowserFile)(ctx) && !isNonShipping(ctx) && !isDetectorMaterial(ctx),
|
|
219
|
+
contentHints: [/sk_|pk_live|AKIA|ghp_|xox[baprs]-|AIza|sk-ant-|eyJhbGciOi|whsec_/],
|
|
220
|
+
detect(ctx) {
|
|
221
|
+
return secretHits(ctx, { skipGeneric: true })
|
|
222
|
+
.filter((match) => match.family !== 'private-key')
|
|
223
|
+
.map((match) => toHit(match, {
|
|
224
|
+
severity: 'critical',
|
|
225
|
+
confidence: 'high',
|
|
226
|
+
message: `A ${match.vendor} credential is bundled into the browser build — it is readable by every visitor.`,
|
|
227
|
+
}));
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
/** VSB-SECRET-006 - default / weak credentials. */
|
|
231
|
+
const defaultCredentials = (0, helpers_1.defineRule)({
|
|
232
|
+
id: 'VSB-SECRET-006',
|
|
233
|
+
slug: 'default-or-weak-credential',
|
|
234
|
+
title: 'Default or trivially weak credential in configuration',
|
|
235
|
+
category: 'secret-entropy',
|
|
236
|
+
severity: 'medium',
|
|
237
|
+
confidence: 'medium',
|
|
238
|
+
cwe: shared_1.CWES.defaultCredentials,
|
|
239
|
+
description: '`postgres:postgres@db` and `password = "admin"` work on every fresh container and are the first thing a scanner tries. Demo defaults survive into production far more often than anyone admits.',
|
|
240
|
+
insecureExample: "DATABASE_URL='postgresql://postgres:postgres@localhost:5432/app'",
|
|
241
|
+
secureExample: 'DATABASE_URL=process.env.DATABASE_URL',
|
|
242
|
+
remediation: [
|
|
243
|
+
'Generate a unique password for every environment and inject it from the platform secret store.',
|
|
244
|
+
'Remove the default from `docker-compose.yml` and `.env.example` samples; use a placeholder the user must replace.',
|
|
245
|
+
'If the credential authenticates a network service, bind it to a private network and rotate it.',
|
|
246
|
+
],
|
|
247
|
+
references: [
|
|
248
|
+
'https://cwe.mitre.org/data/definitions/1392.html',
|
|
249
|
+
'https://owasp.org/www-project-top-ten/2017/A2_2017-Broken_Authentication',
|
|
250
|
+
],
|
|
251
|
+
skipFlags: ['isLockfile', 'isGenerated'],
|
|
252
|
+
appliesTo: (ctx) => !isDetectorMaterial(ctx) && !ctx.flags.isDocumentation,
|
|
253
|
+
contentHints: [/postgres:|mysql:|mongodb:\/\/|redis:\/\/|password\s*[:=]/i],
|
|
254
|
+
detect(ctx) {
|
|
255
|
+
const hits = [];
|
|
256
|
+
const weak = /^(?:admin|root|postgres|password|passwd|123456|changeme|secret|test|guest|user|sa|master|qwerty)$/i;
|
|
257
|
+
for (const match of ctx.code.matchAll(/(?:DATABASE_URL|MONGO(?:DB)?_URL|REDIS_URL|POSTGRES_URL)\s*[:=]\s*['"]?([a-z][a-z0-9+.-]*):\/\/([^:'"@\s]{1,40}):([^'"@\s]{1,60})@/gi)) {
|
|
258
|
+
const user = (match[2] ?? '').trim();
|
|
259
|
+
const pass = (match[3] ?? '').trim();
|
|
260
|
+
if (!weak.test(pass) && (0, text_1.shannonEntropy)(pass) >= 2.5)
|
|
261
|
+
continue;
|
|
262
|
+
hits.push({
|
|
263
|
+
offset: match.index,
|
|
264
|
+
matched: `${match[1]}://${user}:***@`,
|
|
265
|
+
message: `Connection string uses the weak password \`${pass}\` for \`${user}\`.`,
|
|
266
|
+
data: { user },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
for (const match of ctx.code.matchAll(/\b([A-Za-z0-9_]*(?:PASSWORD|PASSWD|PWD|SECRET)[A-Za-z0-9_]*)\s*[:=]\s*(['"])([^'"\n]{1,40})\2/gi)) {
|
|
270
|
+
const name = match[1] ?? '';
|
|
271
|
+
const value = match[3] ?? '';
|
|
272
|
+
if (!weak.test(value))
|
|
273
|
+
continue;
|
|
274
|
+
hits.push({
|
|
275
|
+
offset: match.index,
|
|
276
|
+
matched: `${name} = "${value}"`,
|
|
277
|
+
message: `\`${name}\` is set to the default value \`${value}\`.`,
|
|
278
|
+
data: { variable: name },
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
return hits;
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
/** VSB-SECRET-007 - committed `.env` carrying credentials. */
|
|
285
|
+
const committedEnvFile = (0, helpers_1.defineRule)({
|
|
286
|
+
id: 'VSB-SECRET-007',
|
|
287
|
+
slug: 'env-file-with-credentials',
|
|
288
|
+
title: '.env file with real credentials is tracked by git',
|
|
289
|
+
category: 'secret-entropy',
|
|
290
|
+
severity: 'critical',
|
|
291
|
+
confidence: 'high',
|
|
292
|
+
cwe: shared_1.CWES.credentialLeak,
|
|
293
|
+
description: 'A `.env` file containing live values is in the working tree, and (per the git index) committed. Git history is immutable in practice: anyone with a clone has the keys, even after the file is deleted.',
|
|
294
|
+
insecureExample: 'OPENAI_API_KEY=sk-proj-9xK… # .env, committed',
|
|
295
|
+
secureExample: '# .env.example\nOPENAI_API_KEY=sk-proj-your-key-here',
|
|
296
|
+
remediation: [
|
|
297
|
+
'Rotate every credential in the file, then delete the file from git (`git rm --cached .env`) and add it to `.gitignore`.',
|
|
298
|
+
'Commit a `.env.example` with placeholder values instead.',
|
|
299
|
+
'Purge the file from history with `git filter-repo --path .env --invert-paths`, then force-push and tell every collaborator to re-clone.',
|
|
300
|
+
'Enable secret scanning + push protection on the repository.',
|
|
301
|
+
],
|
|
302
|
+
references: [
|
|
303
|
+
'https://cwe.mitre.org/data/definitions/540.html',
|
|
304
|
+
'https://12factor.net/config',
|
|
305
|
+
],
|
|
306
|
+
appliesTo: (ctx) => ctx.flags.isEnvFile && !/\.(?:example|sample|template|dist|schema)$/i.test(ctx.fileName),
|
|
307
|
+
contentHints: [/=/],
|
|
308
|
+
detect(ctx) {
|
|
309
|
+
const matches = (0, patterns_1.findSecrets)(ctx.code, { skipGeneric: false }).filter((match) => match.family !== 'generic');
|
|
310
|
+
if (!matches.length)
|
|
311
|
+
return [];
|
|
312
|
+
const tracked = ctx.project.gitAvailable
|
|
313
|
+
? ctx.project.trackedPaths.includes(ctx.relativePath)
|
|
314
|
+
: undefined;
|
|
315
|
+
const line = ctx.locate(matches[0]?.offset ?? 0).line;
|
|
316
|
+
const severity = tracked === false ? 'medium' : 'critical';
|
|
317
|
+
return [
|
|
318
|
+
(0, helpers_1.fileHit)(line, {
|
|
319
|
+
matched: ctx.fileName,
|
|
320
|
+
severity,
|
|
321
|
+
confidence: tracked === false ? 'medium' : 'high',
|
|
322
|
+
message: tracked === false
|
|
323
|
+
? `\`${ctx.fileName}\` contains ${matches.length} live credential(s) but is not tracked by git — one \`git add .\` away from a leak.`
|
|
324
|
+
: `\`${ctx.fileName}\` contains ${matches.length} live credential(s) and is committed to the repository.`,
|
|
325
|
+
data: { secrets: matches.length, tracked: tracked ?? null },
|
|
326
|
+
}),
|
|
327
|
+
];
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
exports.SECRET_ENTROPY_RULES = [
|
|
331
|
+
vendorSecret,
|
|
332
|
+
genericSecret,
|
|
333
|
+
privateKeyMaterial,
|
|
334
|
+
privilegedJwt,
|
|
335
|
+
clientVendorSecret,
|
|
336
|
+
defaultCredentials,
|
|
337
|
+
committedEnvFile,
|
|
338
|
+
];
|