keysentinel 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/LICENSE +21 -0
- package/README.md +474 -0
- package/dist/cli.d.ts +5 -0
- package/dist/config.d.ts +66 -0
- package/dist/github.d.ts +54 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +37296 -0
- package/dist/index.js.map +1 -0
- package/dist/licenses.txt +613 -0
- package/dist/mask.d.ts +22 -0
- package/dist/patterns.d.ts +49 -0
- package/dist/scanner.d.ts +36 -0
- package/dist/sourcemap-register.js +1 -0
- package/lib/cli.d.ts +5 -0
- package/lib/cli.js +194 -0
- package/lib/config.d.ts +66 -0
- package/lib/config.js +271 -0
- package/lib/github.d.ts +54 -0
- package/lib/github.js +273 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.js +140 -0
- package/lib/mask.d.ts +22 -0
- package/lib/mask.js +79 -0
- package/lib/patterns.d.ts +49 -0
- package/lib/patterns.js +310 -0
- package/lib/scanner.d.ts +36 -0
- package/lib/scanner.js +216 -0
- package/package.json +43 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* KeySentinel - GitHub Action for scanning PR diffs for secrets
|
|
4
|
+
*/
|
|
5
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
8
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
9
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
10
|
+
}
|
|
11
|
+
Object.defineProperty(o, k2, desc);
|
|
12
|
+
}) : (function(o, m, k, k2) {
|
|
13
|
+
if (k2 === undefined) k2 = k;
|
|
14
|
+
o[k2] = m[k];
|
|
15
|
+
}));
|
|
16
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
17
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
18
|
+
}) : function(o, v) {
|
|
19
|
+
o["default"] = v;
|
|
20
|
+
});
|
|
21
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
22
|
+
var ownKeys = function(o) {
|
|
23
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
24
|
+
var ar = [];
|
|
25
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
26
|
+
return ar;
|
|
27
|
+
};
|
|
28
|
+
return ownKeys(o);
|
|
29
|
+
};
|
|
30
|
+
return function (mod) {
|
|
31
|
+
if (mod && mod.__esModule) return mod;
|
|
32
|
+
var result = {};
|
|
33
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
34
|
+
__setModuleDefault(result, mod);
|
|
35
|
+
return result;
|
|
36
|
+
};
|
|
37
|
+
})();
|
|
38
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
+
const core = __importStar(require("@actions/core"));
|
|
40
|
+
const mask_1 = require("./mask");
|
|
41
|
+
const config_1 = require("./config");
|
|
42
|
+
const patterns_1 = require("./patterns");
|
|
43
|
+
const github_1 = require("./github");
|
|
44
|
+
const scanner_1 = require("./scanner");
|
|
45
|
+
async function run() {
|
|
46
|
+
try {
|
|
47
|
+
core.info('KeySentinel starting...');
|
|
48
|
+
const config = (0, config_1.loadConfig)();
|
|
49
|
+
core.debug(`Config: failOn=${config.failOn}, maxFiles=${config.maxFiles}`);
|
|
50
|
+
const token = core.getInput('github_token', { required: true });
|
|
51
|
+
const octokit = (0, github_1.createOctokit)(token);
|
|
52
|
+
const prContext = (0, github_1.getPRContext)();
|
|
53
|
+
if (!prContext) {
|
|
54
|
+
core.warning('Not running in a pull request context. Skipping scan.');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const { owner, repo, pullNumber } = prContext;
|
|
58
|
+
core.info(`Scanning PR #${pullNumber} in ${owner}/${repo}`);
|
|
59
|
+
const files = await (0, github_1.getPRFiles)(octokit, owner, repo, pullNumber, config.maxFiles);
|
|
60
|
+
core.info(`Found ${files.length} file(s) in PR`);
|
|
61
|
+
if (files.length === 0) {
|
|
62
|
+
core.info('No files to scan');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const patterns = (0, patterns_1.getEnabledPatterns)(config.patterns);
|
|
66
|
+
const headSha = (0, github_1.getPRHeadSha)();
|
|
67
|
+
const allFindings = [];
|
|
68
|
+
let filesScanned = 0;
|
|
69
|
+
let filesSkipped = 0;
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
if (file.status === 'removed') {
|
|
72
|
+
filesSkipped++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if ((0, config_1.shouldIgnoreFile)(file.filename, config.ignore)) {
|
|
76
|
+
core.debug(`Ignoring file: ${file.filename}`);
|
|
77
|
+
filesSkipped++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
let addedLines = [];
|
|
81
|
+
if (file.patch) {
|
|
82
|
+
addedLines = (0, scanner_1.extractAddedLines)(file.patch);
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
core.debug(`No patch for ${file.filename}, fetching content`);
|
|
86
|
+
const content = await (0, github_1.getFileContent)(octokit, owner, repo, file.filename, headSha);
|
|
87
|
+
if (content) {
|
|
88
|
+
const lines = content.split('\n');
|
|
89
|
+
addedLines = lines.map((line, i) => ({ line, lineNumber: i + 1 }));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (addedLines.length === 0) {
|
|
93
|
+
filesSkipped++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const fileFindings = (0, scanner_1.scanLines)(file.filename, addedLines, config, patterns);
|
|
97
|
+
allFindings.push(...fileFindings);
|
|
98
|
+
filesScanned++;
|
|
99
|
+
}
|
|
100
|
+
core.info(`Scanned ${filesScanned} file(s), skipped ${filesSkipped} file(s)`);
|
|
101
|
+
core.info(`Found ${allFindings.length} potential secret(s)`);
|
|
102
|
+
core.setOutput('secrets_found', allFindings.length.toString());
|
|
103
|
+
const safeFindings = allFindings.map(f => ({
|
|
104
|
+
file: f.file,
|
|
105
|
+
line: f.line,
|
|
106
|
+
type: f.type,
|
|
107
|
+
severity: f.severity,
|
|
108
|
+
confidence: f.confidence,
|
|
109
|
+
snippet: f.snippet,
|
|
110
|
+
}));
|
|
111
|
+
core.setOutput('findings', JSON.stringify(safeFindings));
|
|
112
|
+
if (allFindings.length > 0) {
|
|
113
|
+
const report = (0, scanner_1.generateReport)(allFindings, filesScanned);
|
|
114
|
+
await (0, github_1.upsertComment)(octokit, owner, repo, pullNumber, report);
|
|
115
|
+
}
|
|
116
|
+
else if (config.postNoFindings) {
|
|
117
|
+
const report = (0, scanner_1.generateReport)([], filesScanned);
|
|
118
|
+
await (0, github_1.upsertComment)(octokit, owner, repo, pullNumber, report);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
await (0, github_1.deleteExistingComment)(octokit, owner, repo, pullNumber);
|
|
122
|
+
}
|
|
123
|
+
if ((0, scanner_1.shouldFail)(allFindings, config.failOn)) {
|
|
124
|
+
for (const finding of allFindings) {
|
|
125
|
+
core.warning(`${finding.severity.toUpperCase()}: ${finding.type} in ${finding.file}:${finding.line ?? 'N/A'} - ${(0, mask_1.maskSecret)(finding.rawValue)}`);
|
|
126
|
+
}
|
|
127
|
+
core.setFailed(`KeySentinel found ${allFindings.length} potential secret(s) at or above "${config.failOn}" severity`);
|
|
128
|
+
}
|
|
129
|
+
core.info('KeySentinel completed');
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
if (error instanceof Error) {
|
|
133
|
+
core.setFailed(`KeySentinel failed: ${error.message}`);
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
core.setFailed('KeySentinel failed with an unknown error');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
run();
|
package/lib/mask.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mask sensitive data for safe display
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* Mask a secret value, showing only first 3 and last 3 characters
|
|
6
|
+
* @param value The secret value to mask
|
|
7
|
+
* @param showChars Number of characters to show at start and end (default 3)
|
|
8
|
+
*/
|
|
9
|
+
export declare function maskSecret(value: string, showChars?: number): string;
|
|
10
|
+
/**
|
|
11
|
+
* Mask a line of text containing a secret
|
|
12
|
+
* Shows context around the secret while masking the sensitive part
|
|
13
|
+
*/
|
|
14
|
+
export declare function maskLine(line: string, secretValue: string, maxLineLength?: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Mask multiple occurrences of secrets in a text
|
|
17
|
+
*/
|
|
18
|
+
export declare function maskMultiple(text: string, secrets: string[]): string;
|
|
19
|
+
/**
|
|
20
|
+
* Redact a value for logging (complete masking)
|
|
21
|
+
*/
|
|
22
|
+
export declare function redact(value: string): string;
|
package/lib/mask.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Mask sensitive data for safe display
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.maskSecret = maskSecret;
|
|
7
|
+
exports.maskLine = maskLine;
|
|
8
|
+
exports.maskMultiple = maskMultiple;
|
|
9
|
+
exports.redact = redact;
|
|
10
|
+
/**
|
|
11
|
+
* Mask a secret value, showing only first 3 and last 3 characters
|
|
12
|
+
* @param value The secret value to mask
|
|
13
|
+
* @param showChars Number of characters to show at start and end (default 3)
|
|
14
|
+
*/
|
|
15
|
+
function maskSecret(value, showChars = 3) {
|
|
16
|
+
if (!value)
|
|
17
|
+
return '***';
|
|
18
|
+
const len = value.length;
|
|
19
|
+
// If string is too short to meaningfully mask, mask entirely
|
|
20
|
+
if (len <= showChars * 2 + 3) {
|
|
21
|
+
return '*'.repeat(Math.min(len, 10));
|
|
22
|
+
}
|
|
23
|
+
const start = value.slice(0, showChars);
|
|
24
|
+
const end = value.slice(-showChars);
|
|
25
|
+
const maskedLen = Math.min(len - (showChars * 2), 20);
|
|
26
|
+
return `${start}${'*'.repeat(maskedLen)}${end}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Mask a line of text containing a secret
|
|
30
|
+
* Shows context around the secret while masking the sensitive part
|
|
31
|
+
*/
|
|
32
|
+
function maskLine(line, secretValue, maxLineLength = 100) {
|
|
33
|
+
if (!line || !secretValue)
|
|
34
|
+
return line;
|
|
35
|
+
const maskedSecret = maskSecret(secretValue);
|
|
36
|
+
let maskedLine = line.replace(secretValue, maskedSecret);
|
|
37
|
+
// Truncate if too long
|
|
38
|
+
if (maskedLine.length > maxLineLength) {
|
|
39
|
+
const secretPos = maskedLine.indexOf(maskedSecret);
|
|
40
|
+
if (secretPos === -1) {
|
|
41
|
+
// Secret not found (shouldn't happen), just truncate
|
|
42
|
+
return maskedLine.slice(0, maxLineLength - 3) + '...';
|
|
43
|
+
}
|
|
44
|
+
// Try to keep the masked secret visible
|
|
45
|
+
const contextBefore = 20;
|
|
46
|
+
const contextAfter = 20;
|
|
47
|
+
const start = Math.max(0, secretPos - contextBefore);
|
|
48
|
+
const end = Math.min(maskedLine.length, secretPos + maskedSecret.length + contextAfter);
|
|
49
|
+
let result = maskedLine.slice(start, end);
|
|
50
|
+
if (start > 0) {
|
|
51
|
+
result = '...' + result;
|
|
52
|
+
}
|
|
53
|
+
if (end < maskedLine.length) {
|
|
54
|
+
result = result + '...';
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
return maskedLine;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Mask multiple occurrences of secrets in a text
|
|
62
|
+
*/
|
|
63
|
+
function maskMultiple(text, secrets) {
|
|
64
|
+
let result = text;
|
|
65
|
+
for (const secret of secrets) {
|
|
66
|
+
if (secret && result.includes(secret)) {
|
|
67
|
+
result = result.split(secret).join(maskSecret(secret));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Redact a value for logging (complete masking)
|
|
74
|
+
*/
|
|
75
|
+
function redact(value) {
|
|
76
|
+
if (!value)
|
|
77
|
+
return '[REDACTED]';
|
|
78
|
+
return `[REDACTED:${value.length} chars]`;
|
|
79
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secret detection patterns and entropy calculation
|
|
3
|
+
*/
|
|
4
|
+
export type Severity = 'high' | 'medium' | 'low';
|
|
5
|
+
export interface SecretPattern {
|
|
6
|
+
name: string;
|
|
7
|
+
pattern: RegExp;
|
|
8
|
+
severity: Severity;
|
|
9
|
+
group: string;
|
|
10
|
+
}
|
|
11
|
+
export interface Finding {
|
|
12
|
+
file: string;
|
|
13
|
+
line: number | null;
|
|
14
|
+
type: string;
|
|
15
|
+
severity: Severity;
|
|
16
|
+
confidence: 'high' | 'medium' | 'low';
|
|
17
|
+
snippet: string;
|
|
18
|
+
rawValue: string;
|
|
19
|
+
}
|
|
20
|
+
export declare const SECRET_PATTERNS: SecretPattern[];
|
|
21
|
+
/**
|
|
22
|
+
* Calculate Shannon entropy of a string
|
|
23
|
+
*/
|
|
24
|
+
export declare function calculateEntropy(str: string): number;
|
|
25
|
+
/**
|
|
26
|
+
* Check if a string looks like base64
|
|
27
|
+
*/
|
|
28
|
+
export declare function isBase64Like(str: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Check if string is a common non-secret pattern
|
|
31
|
+
*/
|
|
32
|
+
export declare function isLikelyNonSecret(str: string): boolean;
|
|
33
|
+
export interface EntropyConfig {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
minLength: number;
|
|
36
|
+
threshold: number;
|
|
37
|
+
ignoreBase64Like: boolean;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Detect high-entropy strings that might be secrets
|
|
41
|
+
*/
|
|
42
|
+
export declare function detectHighEntropyStrings(text: string, config: EntropyConfig): {
|
|
43
|
+
value: string;
|
|
44
|
+
entropy: number;
|
|
45
|
+
}[];
|
|
46
|
+
/**
|
|
47
|
+
* Get patterns filtered by enabled groups
|
|
48
|
+
*/
|
|
49
|
+
export declare function getEnabledPatterns(enabledGroups?: Record<string, boolean>): SecretPattern[];
|
package/lib/patterns.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Secret detection patterns and entropy calculation
|
|
4
|
+
*/
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.SECRET_PATTERNS = void 0;
|
|
7
|
+
exports.calculateEntropy = calculateEntropy;
|
|
8
|
+
exports.isBase64Like = isBase64Like;
|
|
9
|
+
exports.isLikelyNonSecret = isLikelyNonSecret;
|
|
10
|
+
exports.detectHighEntropyStrings = detectHighEntropyStrings;
|
|
11
|
+
exports.getEnabledPatterns = getEnabledPatterns;
|
|
12
|
+
// Secret detection patterns organized by group
|
|
13
|
+
exports.SECRET_PATTERNS = [
|
|
14
|
+
// AWS
|
|
15
|
+
{
|
|
16
|
+
name: 'AWS Access Key ID',
|
|
17
|
+
pattern: /\b(AKIA[0-9A-Z]{16})\b/g,
|
|
18
|
+
severity: 'high',
|
|
19
|
+
group: 'aws'
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'AWS Secret Access Key',
|
|
23
|
+
pattern: /\b([A-Za-z0-9/+=]{40})\b/g,
|
|
24
|
+
severity: 'high',
|
|
25
|
+
group: 'aws'
|
|
26
|
+
},
|
|
27
|
+
// GitHub
|
|
28
|
+
{
|
|
29
|
+
name: 'GitHub Personal Access Token',
|
|
30
|
+
pattern: /\b(ghp_[a-zA-Z0-9]{36})\b/g,
|
|
31
|
+
severity: 'high',
|
|
32
|
+
group: 'github'
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'GitHub OAuth Access Token',
|
|
36
|
+
pattern: /\b(gho_[a-zA-Z0-9]{36})\b/g,
|
|
37
|
+
severity: 'high',
|
|
38
|
+
group: 'github'
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'GitHub App Token',
|
|
42
|
+
pattern: /\b(ghu_[a-zA-Z0-9]{36})\b/g,
|
|
43
|
+
severity: 'high',
|
|
44
|
+
group: 'github'
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'GitHub Fine-Grained Token',
|
|
48
|
+
pattern: /\b(github_pat_[a-zA-Z0-9_]{22,82})\b/g,
|
|
49
|
+
severity: 'high',
|
|
50
|
+
group: 'github'
|
|
51
|
+
},
|
|
52
|
+
// Slack
|
|
53
|
+
{
|
|
54
|
+
name: 'Slack Bot Token',
|
|
55
|
+
pattern: /\b(xoxb-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24})\b/g,
|
|
56
|
+
severity: 'high',
|
|
57
|
+
group: 'slack'
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: 'Slack User Token',
|
|
61
|
+
pattern: /\b(xoxp-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9]{24})\b/g,
|
|
62
|
+
severity: 'high',
|
|
63
|
+
group: 'slack'
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'Slack Webhook URL',
|
|
67
|
+
pattern: /https:\/\/hooks\.slack\.com\/services\/T[a-zA-Z0-9_]{8,}\/B[a-zA-Z0-9_]{8,}\/[a-zA-Z0-9_]{24}/g,
|
|
68
|
+
severity: 'high',
|
|
69
|
+
group: 'slack'
|
|
70
|
+
},
|
|
71
|
+
// Generic API Keys
|
|
72
|
+
{
|
|
73
|
+
name: 'Generic API Key',
|
|
74
|
+
pattern: /(?:api[_-]?key|apikey)\s*[:=]\s*['"]?([a-zA-Z0-9_\-]{20,})['"]?/gi,
|
|
75
|
+
severity: 'medium',
|
|
76
|
+
group: 'generic'
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'Generic Secret',
|
|
80
|
+
pattern: /(?:secret|secret[_-]?key)\s*[:=]\s*['"]?([a-zA-Z0-9_\-]{16,})['"]?/gi,
|
|
81
|
+
severity: 'medium',
|
|
82
|
+
group: 'generic'
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'Generic Password',
|
|
86
|
+
pattern: /(?:password|passwd|pwd)\s*[:=]\s*['"]?([^\s'"]{8,})['"]?/gi,
|
|
87
|
+
severity: 'medium',
|
|
88
|
+
group: 'generic'
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
name: 'Bearer Token',
|
|
92
|
+
pattern: /Bearer\s+([a-zA-Z0-9_\-.~+/]+=*)/gi,
|
|
93
|
+
severity: 'medium',
|
|
94
|
+
group: 'generic'
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
name: 'Basic Auth',
|
|
98
|
+
pattern: /Basic\s+([a-zA-Z0-9+/=]{20,})/gi,
|
|
99
|
+
severity: 'medium',
|
|
100
|
+
group: 'generic'
|
|
101
|
+
},
|
|
102
|
+
// Private Keys
|
|
103
|
+
{
|
|
104
|
+
name: 'RSA Private Key',
|
|
105
|
+
pattern: /-----BEGIN RSA PRIVATE KEY-----/g,
|
|
106
|
+
severity: 'high',
|
|
107
|
+
group: 'keys'
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
name: 'SSH Private Key',
|
|
111
|
+
pattern: /-----BEGIN OPENSSH PRIVATE KEY-----/g,
|
|
112
|
+
severity: 'high',
|
|
113
|
+
group: 'keys'
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: 'PGP Private Key',
|
|
117
|
+
pattern: /-----BEGIN PGP PRIVATE KEY BLOCK-----/g,
|
|
118
|
+
severity: 'high',
|
|
119
|
+
group: 'keys'
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
name: 'EC Private Key',
|
|
123
|
+
pattern: /-----BEGIN EC PRIVATE KEY-----/g,
|
|
124
|
+
severity: 'high',
|
|
125
|
+
group: 'keys'
|
|
126
|
+
},
|
|
127
|
+
// Cloud Providers
|
|
128
|
+
{
|
|
129
|
+
name: 'Google API Key',
|
|
130
|
+
pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
131
|
+
severity: 'high',
|
|
132
|
+
group: 'google'
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
name: 'Google OAuth Client Secret',
|
|
136
|
+
pattern: /\b([a-zA-Z0-9_-]{24}\.apps\.googleusercontent\.com)\b/g,
|
|
137
|
+
severity: 'medium',
|
|
138
|
+
group: 'google'
|
|
139
|
+
},
|
|
140
|
+
// Stripe
|
|
141
|
+
{
|
|
142
|
+
name: 'Stripe Live Key',
|
|
143
|
+
pattern: /\bsk_live_[0-9a-zA-Z]{24,}\b/g,
|
|
144
|
+
severity: 'high',
|
|
145
|
+
group: 'stripe'
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
name: 'Stripe Test Key',
|
|
149
|
+
pattern: /\bsk_test_[0-9a-zA-Z]{24,}\b/g,
|
|
150
|
+
severity: 'low',
|
|
151
|
+
group: 'stripe'
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'Stripe Restricted Key',
|
|
155
|
+
pattern: /\brk_live_[0-9a-zA-Z]{24,}\b/g,
|
|
156
|
+
severity: 'high',
|
|
157
|
+
group: 'stripe'
|
|
158
|
+
},
|
|
159
|
+
// Database Connection Strings
|
|
160
|
+
{
|
|
161
|
+
name: 'Database Connection String',
|
|
162
|
+
pattern: /(?:mongodb|postgres|mysql|redis):\/\/[^\s'"]+:[^\s'"]+@[^\s'"]+/gi,
|
|
163
|
+
severity: 'high',
|
|
164
|
+
group: 'database'
|
|
165
|
+
},
|
|
166
|
+
// Twilio
|
|
167
|
+
{
|
|
168
|
+
name: 'Twilio API Key',
|
|
169
|
+
pattern: /\bSK[0-9a-fA-F]{32}\b/g,
|
|
170
|
+
severity: 'high',
|
|
171
|
+
group: 'twilio'
|
|
172
|
+
},
|
|
173
|
+
// SendGrid
|
|
174
|
+
{
|
|
175
|
+
name: 'SendGrid API Key',
|
|
176
|
+
pattern: /\bSG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}\b/g,
|
|
177
|
+
severity: 'high',
|
|
178
|
+
group: 'sendgrid'
|
|
179
|
+
},
|
|
180
|
+
// Mailchimp
|
|
181
|
+
{
|
|
182
|
+
name: 'Mailchimp API Key',
|
|
183
|
+
pattern: /\b[0-9a-f]{32}-us[0-9]{1,2}\b/g,
|
|
184
|
+
severity: 'medium',
|
|
185
|
+
group: 'mailchimp'
|
|
186
|
+
},
|
|
187
|
+
// NPM
|
|
188
|
+
{
|
|
189
|
+
name: 'NPM Token',
|
|
190
|
+
pattern: /\bnpm_[a-zA-Z0-9]{36}\b/g,
|
|
191
|
+
severity: 'high',
|
|
192
|
+
group: 'npm'
|
|
193
|
+
},
|
|
194
|
+
// Discord
|
|
195
|
+
{
|
|
196
|
+
name: 'Discord Bot Token',
|
|
197
|
+
pattern: /\b[MN][A-Za-z\d]{23,}\.[\w-]{6}\.[\w-]{27}\b/g,
|
|
198
|
+
severity: 'high',
|
|
199
|
+
group: 'discord'
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'Discord Webhook URL',
|
|
203
|
+
pattern: /https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[a-zA-Z0-9_-]+/g,
|
|
204
|
+
severity: 'medium',
|
|
205
|
+
group: 'discord'
|
|
206
|
+
},
|
|
207
|
+
// Heroku
|
|
208
|
+
{
|
|
209
|
+
name: 'Heroku API Key',
|
|
210
|
+
pattern: /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g,
|
|
211
|
+
severity: 'medium',
|
|
212
|
+
group: 'heroku'
|
|
213
|
+
},
|
|
214
|
+
// JWT
|
|
215
|
+
{
|
|
216
|
+
name: 'JSON Web Token',
|
|
217
|
+
pattern: /\beyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]+\b/g,
|
|
218
|
+
severity: 'medium',
|
|
219
|
+
group: 'jwt'
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
/**
|
|
223
|
+
* Calculate Shannon entropy of a string
|
|
224
|
+
*/
|
|
225
|
+
function calculateEntropy(str) {
|
|
226
|
+
if (!str || str.length === 0)
|
|
227
|
+
return 0;
|
|
228
|
+
const freq = {};
|
|
229
|
+
for (const char of str) {
|
|
230
|
+
freq[char] = (freq[char] || 0) + 1;
|
|
231
|
+
}
|
|
232
|
+
let entropy = 0;
|
|
233
|
+
const len = str.length;
|
|
234
|
+
for (const count of Object.values(freq)) {
|
|
235
|
+
const p = count / len;
|
|
236
|
+
entropy -= p * Math.log2(p);
|
|
237
|
+
}
|
|
238
|
+
return entropy;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Check if a string looks like base64
|
|
242
|
+
*/
|
|
243
|
+
function isBase64Like(str) {
|
|
244
|
+
// Base64 typically has uniform character distribution from a limited alphabet
|
|
245
|
+
const base64Regex = /^[A-Za-z0-9+/]+=*$/;
|
|
246
|
+
return base64Regex.test(str) && str.length % 4 === 0;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Check if string is a common non-secret pattern
|
|
250
|
+
*/
|
|
251
|
+
function isLikelyNonSecret(str) {
|
|
252
|
+
// Common patterns that are not secrets
|
|
253
|
+
const nonSecretPatterns = [
|
|
254
|
+
/^[0-9]+$/, // Pure numbers
|
|
255
|
+
/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, // UUID (often non-secret)
|
|
256
|
+
/^(true|false|null|undefined|none)$/i, // Boolean/null literals
|
|
257
|
+
/^[a-z]+(_[a-z]+)*$/i, // snake_case identifiers
|
|
258
|
+
/^[a-z]+(-[a-z]+)*$/i, // kebab-case identifiers
|
|
259
|
+
/^v?\d+\.\d+\.\d+/, // Version strings
|
|
260
|
+
/^https?:\/\/[^\s@]+$/, // URLs without credentials
|
|
261
|
+
/^[\w.+-]+@[\w.-]+\.[a-z]{2,}$/i, // Email addresses (not secrets themselves)
|
|
262
|
+
/^sha[0-9]{3}:[a-f0-9]{64}$/i, // SHA hashes
|
|
263
|
+
/^[0-9a-f]{64}$/i, // Hex hashes (SHA256)
|
|
264
|
+
/^[0-9a-f]{40}$/i, // Git commit hashes
|
|
265
|
+
];
|
|
266
|
+
return nonSecretPatterns.some(p => p.test(str));
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Detect high-entropy strings that might be secrets
|
|
270
|
+
*/
|
|
271
|
+
function detectHighEntropyStrings(text, config) {
|
|
272
|
+
if (!config.enabled)
|
|
273
|
+
return [];
|
|
274
|
+
const results = [];
|
|
275
|
+
// Match potential tokens: alphanumeric strings with special chars
|
|
276
|
+
const tokenPattern = /\b[a-zA-Z0-9_\-+/]{20,}\b/g;
|
|
277
|
+
let match;
|
|
278
|
+
while ((match = tokenPattern.exec(text)) !== null) {
|
|
279
|
+
const candidate = match[0];
|
|
280
|
+
// Skip if too short
|
|
281
|
+
if (candidate.length < config.minLength)
|
|
282
|
+
continue;
|
|
283
|
+
// Skip known non-secrets
|
|
284
|
+
if (isLikelyNonSecret(candidate))
|
|
285
|
+
continue;
|
|
286
|
+
// Skip base64-like if configured
|
|
287
|
+
if (config.ignoreBase64Like && isBase64Like(candidate))
|
|
288
|
+
continue;
|
|
289
|
+
const entropy = calculateEntropy(candidate);
|
|
290
|
+
if (entropy >= config.threshold) {
|
|
291
|
+
results.push({ value: candidate, entropy });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return results;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Get patterns filtered by enabled groups
|
|
298
|
+
*/
|
|
299
|
+
function getEnabledPatterns(enabledGroups) {
|
|
300
|
+
if (!enabledGroups)
|
|
301
|
+
return exports.SECRET_PATTERNS;
|
|
302
|
+
return exports.SECRET_PATTERNS.filter(pattern => {
|
|
303
|
+
const groupSetting = enabledGroups[pattern.group];
|
|
304
|
+
// If group is explicitly disabled, filter out
|
|
305
|
+
if (groupSetting === false)
|
|
306
|
+
return false;
|
|
307
|
+
// Otherwise include
|
|
308
|
+
return true;
|
|
309
|
+
});
|
|
310
|
+
}
|
package/lib/scanner.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core scanning logic - pure functions for testability
|
|
3
|
+
*/
|
|
4
|
+
import { Finding, Severity, SecretPattern } from './patterns';
|
|
5
|
+
import { Config } from './config';
|
|
6
|
+
/**
|
|
7
|
+
* Extract added lines from a unified diff patch
|
|
8
|
+
* Returns array of { line: text, lineNumber: number }
|
|
9
|
+
*/
|
|
10
|
+
export declare function extractAddedLines(patch: string): {
|
|
11
|
+
line: string;
|
|
12
|
+
lineNumber: number;
|
|
13
|
+
}[];
|
|
14
|
+
/**
|
|
15
|
+
* Scan text for secrets using regex patterns
|
|
16
|
+
*/
|
|
17
|
+
export declare function scanWithPatterns(text: string, patterns: SecretPattern[], allowlist: RegExp[]): {
|
|
18
|
+
pattern: SecretPattern;
|
|
19
|
+
match: string;
|
|
20
|
+
index: number;
|
|
21
|
+
}[];
|
|
22
|
+
/**
|
|
23
|
+
* Scan a single file's added lines for secrets
|
|
24
|
+
*/
|
|
25
|
+
export declare function scanLines(filename: string, addedLines: {
|
|
26
|
+
line: string;
|
|
27
|
+
lineNumber: number;
|
|
28
|
+
}[], config: Config, patterns: SecretPattern[]): Finding[];
|
|
29
|
+
/**
|
|
30
|
+
* Generate markdown report from findings
|
|
31
|
+
*/
|
|
32
|
+
export declare function generateReport(findings: Finding[], filesScanned: number): string;
|
|
33
|
+
/**
|
|
34
|
+
* Check if workflow should fail based on severity threshold
|
|
35
|
+
*/
|
|
36
|
+
export declare function shouldFail(findings: Finding[], failOn: Severity | 'off'): boolean;
|