context-armor 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 +190 -0
- package/README.md +260 -0
- package/action.yml +34 -0
- package/bin/context-armor.js +9 -0
- package/package.json +52 -0
- package/src/cli.js +290 -0
- package/src/config.js +78 -0
- package/src/documents.js +154 -0
- package/src/ignore.js +87 -0
- package/src/index.js +4 -0
- package/src/policy.js +85 -0
- package/src/reporters.js +148 -0
- package/src/rules.js +225 -0
- package/src/scanner.js +289 -0
- package/src/validators.js +48 -0
package/src/reporters.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
const colors = {
|
|
4
|
+
critical: "\u001b[31;1m",
|
|
5
|
+
high: "\u001b[31m",
|
|
6
|
+
medium: "\u001b[33m",
|
|
7
|
+
low: "\u001b[36m",
|
|
8
|
+
dim: "\u001b[2m",
|
|
9
|
+
reset: "\u001b[0m",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function colorize(value, color, enabled) {
|
|
13
|
+
return enabled ? `${colors[color]}${value}${colors.reset}` : value;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatPretty(result, options = {}) {
|
|
17
|
+
const useColor = options.color ?? (process.stdout.isTTY && !process.env.NO_COLOR);
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push(
|
|
20
|
+
`Context Armor scanned ${result.files.discovered} files in ${result.durationMs}ms`,
|
|
21
|
+
);
|
|
22
|
+
lines.push("");
|
|
23
|
+
|
|
24
|
+
if (result.findings.length === 0) {
|
|
25
|
+
lines.push(colorize("No sensitive findings detected.", "low", useColor));
|
|
26
|
+
} else {
|
|
27
|
+
for (const finding of result.findings) {
|
|
28
|
+
const severity = finding.severity.toUpperCase().padEnd(8);
|
|
29
|
+
const location =
|
|
30
|
+
finding.kind === "content"
|
|
31
|
+
? `${finding.path}:${finding.line}:${finding.column}`
|
|
32
|
+
: finding.path;
|
|
33
|
+
lines.push(
|
|
34
|
+
`${colorize(severity, finding.severity, useColor)} ${location} ${colorize(
|
|
35
|
+
finding.ruleId,
|
|
36
|
+
"dim",
|
|
37
|
+
useColor,
|
|
38
|
+
)}`,
|
|
39
|
+
);
|
|
40
|
+
lines.push(` ${finding.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const counts = result.summary.bySeverity;
|
|
45
|
+
lines.push("");
|
|
46
|
+
lines.push(
|
|
47
|
+
[
|
|
48
|
+
colorize(`${counts.critical || 0} critical`, "critical", useColor),
|
|
49
|
+
colorize(`${counts.high || 0} high`, "high", useColor),
|
|
50
|
+
colorize(`${counts.medium || 0} medium`, "medium", useColor),
|
|
51
|
+
colorize(`${counts.low || 0} low`, "low", useColor),
|
|
52
|
+
].join(" · "),
|
|
53
|
+
);
|
|
54
|
+
if (result.suppressed) lines.push(`${result.suppressed} baseline findings suppressed`);
|
|
55
|
+
if (result.warnings.length) lines.push(`${result.warnings.length} scan warnings`);
|
|
56
|
+
if (result.files.skipped) lines.push(`${result.files.skipped} files skipped or uninspected`);
|
|
57
|
+
return `${lines.join("\n")}\n`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatJson(result) {
|
|
61
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function sarifLevel(severity) {
|
|
65
|
+
if (severity === "critical" || severity === "high") return "error";
|
|
66
|
+
if (severity === "medium") return "warning";
|
|
67
|
+
return "note";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function formatSarif(result) {
|
|
71
|
+
const ruleMap = new Map();
|
|
72
|
+
for (const finding of result.findings) {
|
|
73
|
+
if (!ruleMap.has(finding.ruleId)) {
|
|
74
|
+
ruleMap.set(finding.ruleId, {
|
|
75
|
+
id: finding.ruleId,
|
|
76
|
+
name: finding.title.replace(/\s+/g, ""),
|
|
77
|
+
shortDescription: { text: finding.title },
|
|
78
|
+
fullDescription: { text: finding.message },
|
|
79
|
+
defaultConfiguration: { level: sarifLevel(finding.severity) },
|
|
80
|
+
properties: {
|
|
81
|
+
precision: "high",
|
|
82
|
+
securitySeverity:
|
|
83
|
+
finding.severity === "critical"
|
|
84
|
+
? "9.0"
|
|
85
|
+
: finding.severity === "high"
|
|
86
|
+
? "7.0"
|
|
87
|
+
: finding.severity === "medium"
|
|
88
|
+
? "4.0"
|
|
89
|
+
: "1.0",
|
|
90
|
+
tags: ["security", "privacy", ...finding.tags],
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const sarif = {
|
|
97
|
+
version: "2.1.0",
|
|
98
|
+
$schema:
|
|
99
|
+
"https://json.schemastore.org/sarif-2.1.0.json",
|
|
100
|
+
runs: [
|
|
101
|
+
{
|
|
102
|
+
tool: {
|
|
103
|
+
driver: {
|
|
104
|
+
name: result.tool.name,
|
|
105
|
+
semanticVersion: result.tool.version,
|
|
106
|
+
informationUri: "https://github.com/feelyday/context-armor",
|
|
107
|
+
rules: [...ruleMap.values()],
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
originalUriBaseIds: {
|
|
111
|
+
"%SRCROOT%": {
|
|
112
|
+
uri: `file://${result.root.split(path.sep).map(encodeURIComponent).join("/")}/`,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
results: result.findings.map((finding) => ({
|
|
116
|
+
ruleId: finding.ruleId,
|
|
117
|
+
level: sarifLevel(finding.severity),
|
|
118
|
+
message: { text: finding.message },
|
|
119
|
+
partialFingerprints: {
|
|
120
|
+
contextArmorFingerprint: finding.fingerprint,
|
|
121
|
+
},
|
|
122
|
+
locations: [
|
|
123
|
+
{
|
|
124
|
+
physicalLocation: {
|
|
125
|
+
artifactLocation: {
|
|
126
|
+
uri: finding.path.split("/").map(encodeURIComponent).join("/"),
|
|
127
|
+
uriBaseId: "%SRCROOT%",
|
|
128
|
+
},
|
|
129
|
+
region: {
|
|
130
|
+
startLine: finding.line,
|
|
131
|
+
startColumn: finding.column,
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
properties: { severity: finding.severity, kind: finding.kind },
|
|
137
|
+
})),
|
|
138
|
+
invocations: [
|
|
139
|
+
{
|
|
140
|
+
executionSuccessful: result.warnings.length === 0,
|
|
141
|
+
endTimeUtc: result.scannedAt,
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
return `${JSON.stringify(sarif, null, 2)}\n`;
|
|
148
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { isLikelyPlaceholder, isValidKoreanRrn, passesLuhn } from "./validators.js";
|
|
2
|
+
|
|
3
|
+
export const severityRank = Object.freeze({
|
|
4
|
+
none: 99,
|
|
5
|
+
critical: 0,
|
|
6
|
+
high: 1,
|
|
7
|
+
medium: 2,
|
|
8
|
+
low: 3,
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
function patternRule({
|
|
12
|
+
id,
|
|
13
|
+
title,
|
|
14
|
+
severity,
|
|
15
|
+
pattern,
|
|
16
|
+
description,
|
|
17
|
+
validate,
|
|
18
|
+
capture = 0,
|
|
19
|
+
tags = [],
|
|
20
|
+
}) {
|
|
21
|
+
return { id, title, severity, pattern, description, validate, capture, tags };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const contentRules = [
|
|
25
|
+
patternRule({
|
|
26
|
+
id: "SECRET_PRIVATE_KEY",
|
|
27
|
+
title: "Private key",
|
|
28
|
+
severity: "critical",
|
|
29
|
+
pattern: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/g,
|
|
30
|
+
description: "A private key can grant direct access to systems or identities.",
|
|
31
|
+
tags: ["secret", "credential"],
|
|
32
|
+
}),
|
|
33
|
+
patternRule({
|
|
34
|
+
id: "SECRET_AWS_ACCESS_KEY",
|
|
35
|
+
title: "AWS access key",
|
|
36
|
+
severity: "critical",
|
|
37
|
+
pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g,
|
|
38
|
+
description: "An AWS access key identifier was found.",
|
|
39
|
+
tags: ["secret", "cloud"],
|
|
40
|
+
}),
|
|
41
|
+
patternRule({
|
|
42
|
+
id: "SECRET_GITHUB_TOKEN",
|
|
43
|
+
title: "GitHub token",
|
|
44
|
+
severity: "critical",
|
|
45
|
+
pattern: /\b(?:gh[pousr]_[A-Za-z0-9_]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g,
|
|
46
|
+
description: "A GitHub authentication token was found.",
|
|
47
|
+
tags: ["secret", "source-control"],
|
|
48
|
+
}),
|
|
49
|
+
patternRule({
|
|
50
|
+
id: "SECRET_ANTHROPIC_KEY",
|
|
51
|
+
title: "Anthropic API key",
|
|
52
|
+
severity: "critical",
|
|
53
|
+
pattern: /\bsk-ant-(?:api\d{2}-)?[A-Za-z0-9_-]{32,}\b/g,
|
|
54
|
+
description: "An Anthropic API key was found.",
|
|
55
|
+
tags: ["secret", "ai"],
|
|
56
|
+
}),
|
|
57
|
+
patternRule({
|
|
58
|
+
id: "SECRET_OPENAI_KEY",
|
|
59
|
+
title: "OpenAI API key",
|
|
60
|
+
severity: "critical",
|
|
61
|
+
pattern: /\bsk-(?:proj-|svcacct-)?[A-Za-z0-9_-]{32,}\b/g,
|
|
62
|
+
description: "An OpenAI-style API key was found.",
|
|
63
|
+
tags: ["secret", "ai"],
|
|
64
|
+
}),
|
|
65
|
+
patternRule({
|
|
66
|
+
id: "SECRET_GOOGLE_API_KEY",
|
|
67
|
+
title: "Google API key",
|
|
68
|
+
severity: "critical",
|
|
69
|
+
pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g,
|
|
70
|
+
description: "A Google API key was found.",
|
|
71
|
+
tags: ["secret", "cloud"],
|
|
72
|
+
}),
|
|
73
|
+
patternRule({
|
|
74
|
+
id: "SECRET_SLACK_TOKEN",
|
|
75
|
+
title: "Slack token",
|
|
76
|
+
severity: "critical",
|
|
77
|
+
pattern: /\bxox[baprs]-[0-9A-Za-z-]{10,}\b/g,
|
|
78
|
+
description: "A Slack token was found.",
|
|
79
|
+
tags: ["secret", "messaging"],
|
|
80
|
+
}),
|
|
81
|
+
patternRule({
|
|
82
|
+
id: "SECRET_STRIPE_LIVE_KEY",
|
|
83
|
+
title: "Stripe live key",
|
|
84
|
+
severity: "critical",
|
|
85
|
+
pattern: /\b(?:sk|rk)_live_[0-9A-Za-z]{16,}\b/g,
|
|
86
|
+
description: "A live Stripe secret or restricted key was found.",
|
|
87
|
+
tags: ["secret", "payments"],
|
|
88
|
+
}),
|
|
89
|
+
patternRule({
|
|
90
|
+
id: "SECRET_DATABASE_URL",
|
|
91
|
+
title: "Database URL with credentials",
|
|
92
|
+
severity: "critical",
|
|
93
|
+
pattern:
|
|
94
|
+
/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/[^/\s:@]+:[^@\s/]+@[^\s"'`]+/gi,
|
|
95
|
+
description: "A database connection URL contains embedded credentials.",
|
|
96
|
+
tags: ["secret", "database"],
|
|
97
|
+
}),
|
|
98
|
+
patternRule({
|
|
99
|
+
id: "SECRET_JWT",
|
|
100
|
+
title: "JSON Web Token",
|
|
101
|
+
severity: "high",
|
|
102
|
+
pattern: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g,
|
|
103
|
+
description: "A JWT may contain identity data or grant access.",
|
|
104
|
+
tags: ["secret", "identity"],
|
|
105
|
+
}),
|
|
106
|
+
patternRule({
|
|
107
|
+
id: "SECRET_PASSWORD_ASSIGNMENT",
|
|
108
|
+
title: "Hard-coded password",
|
|
109
|
+
severity: "high",
|
|
110
|
+
pattern:
|
|
111
|
+
/\b(?:password|passwd|pwd|secret)\s*(?:=|:)\s*["']?([^\s"',;}{]{7,})["']?/gi,
|
|
112
|
+
capture: 1,
|
|
113
|
+
validate: (value) => !isLikelyPlaceholder(value),
|
|
114
|
+
description: "A password-like value appears to be hard-coded.",
|
|
115
|
+
tags: ["secret", "credential"],
|
|
116
|
+
}),
|
|
117
|
+
patternRule({
|
|
118
|
+
id: "PII_KR_RRN",
|
|
119
|
+
title: "Korean resident registration number",
|
|
120
|
+
severity: "critical",
|
|
121
|
+
pattern: /\b\d{6}[- ]?[1-8]\d{6}\b/g,
|
|
122
|
+
validate: isValidKoreanRrn,
|
|
123
|
+
description: "A checksum-valid Korean resident registration number was found.",
|
|
124
|
+
tags: ["pii", "korea", "identity"],
|
|
125
|
+
}),
|
|
126
|
+
patternRule({
|
|
127
|
+
id: "PII_CREDIT_CARD",
|
|
128
|
+
title: "Payment card number",
|
|
129
|
+
severity: "critical",
|
|
130
|
+
pattern:
|
|
131
|
+
/(?<!\d)(?:\d{4}[ -]\d{4}[ -]\d{4}(?:[ -]\d{1,7})?|\d{4}[ -]\d{6}[ -]\d{5})(?!\d)/g,
|
|
132
|
+
validate: passesLuhn,
|
|
133
|
+
description: "A formatted, Luhn-valid payment card number was found.",
|
|
134
|
+
tags: ["pii", "payments"],
|
|
135
|
+
}),
|
|
136
|
+
patternRule({
|
|
137
|
+
id: "PII_US_SSN",
|
|
138
|
+
title: "US Social Security number",
|
|
139
|
+
severity: "high",
|
|
140
|
+
pattern: /\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g,
|
|
141
|
+
description: "A hyphenated value matching a valid US SSN range was found.",
|
|
142
|
+
tags: ["pii", "us", "identity"],
|
|
143
|
+
}),
|
|
144
|
+
patternRule({
|
|
145
|
+
id: "PII_KR_PHONE",
|
|
146
|
+
title: "Korean phone number",
|
|
147
|
+
severity: "medium",
|
|
148
|
+
pattern: /(?<!\d)(?:\+82[- ]?1[016789]|01[016789])[- ]?\d{3,4}[- ]?\d{4}(?!\d)/g,
|
|
149
|
+
description: "A Korean mobile phone number was found.",
|
|
150
|
+
tags: ["pii", "korea", "contact"],
|
|
151
|
+
}),
|
|
152
|
+
patternRule({
|
|
153
|
+
id: "PII_EMAIL",
|
|
154
|
+
title: "Email address",
|
|
155
|
+
severity: "low",
|
|
156
|
+
pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,63}\b/gi,
|
|
157
|
+
validate: (value) => !/\b(?:example\.(?:com|org|net)|test\.invalid)\b/i.test(value),
|
|
158
|
+
description: "An email address was found.",
|
|
159
|
+
tags: ["pii", "contact"],
|
|
160
|
+
}),
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
export const filenameRules = [
|
|
164
|
+
{
|
|
165
|
+
id: "FILE_ENV",
|
|
166
|
+
title: "Environment file",
|
|
167
|
+
severity: "high",
|
|
168
|
+
pattern: /(^|\/)\.env(?:\.|$)/i,
|
|
169
|
+
description: "Environment files commonly contain credentials.",
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
id: "FILE_PRIVATE_KEY",
|
|
173
|
+
title: "Private key file",
|
|
174
|
+
severity: "critical",
|
|
175
|
+
pattern: /(^|\/)(?:id_(?:rsa|dsa|ecdsa|ed25519)|[^/]+\.(?:pem|key|p12|pfx))$/i,
|
|
176
|
+
description: "The filename indicates private-key material.",
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
id: "FILE_CREDENTIALS",
|
|
180
|
+
title: "Credential file",
|
|
181
|
+
severity: "high",
|
|
182
|
+
pattern:
|
|
183
|
+
/(^|\/)(?:credentials?|secrets?|service[-_ ]?account)(?:\.[^/]*)?$/i,
|
|
184
|
+
description: "The filename indicates credentials or secrets.",
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
id: "FILE_AUTH_CONFIG",
|
|
188
|
+
title: "Package registry authentication config",
|
|
189
|
+
severity: "medium",
|
|
190
|
+
pattern: /(^|\/)(?:\.npmrc|\.pypirc|\.netrc)$/i,
|
|
191
|
+
description: "Package registry configuration can contain authentication tokens.",
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
id: "FILE_IDENTITY_DOCUMENT_KR",
|
|
195
|
+
title: "Korean identity document",
|
|
196
|
+
severity: "high",
|
|
197
|
+
pattern: /(?:주민등록|기본증명|가족관계|여권|운전면허)/i,
|
|
198
|
+
description: "The filename indicates a Korean identity document.",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: "FILE_FINANCIAL_DOCUMENT_KR",
|
|
202
|
+
title: "Korean financial document",
|
|
203
|
+
severity: "medium",
|
|
204
|
+
pattern: /(?:통장사본|계좌|세금|부가세|원천징수|급여)/i,
|
|
205
|
+
description: "The filename indicates a financial or tax document.",
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
id: "FILE_LEGAL_DOCUMENT_KR",
|
|
209
|
+
title: "Korean legal document",
|
|
210
|
+
severity: "medium",
|
|
211
|
+
pattern: /(?:계약서|비밀유지|인감|등기부|사업자등록)/i,
|
|
212
|
+
description: "The filename indicates a legal or registration document.",
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
id: "FILE_DATABASE_DUMP",
|
|
216
|
+
title: "Database dump",
|
|
217
|
+
severity: "high",
|
|
218
|
+
pattern: /\.(?:sql|sqlite3?|dump|bak)$/i,
|
|
219
|
+
description: "Database exports can contain credentials and personal data.",
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
export function clonePattern(pattern) {
|
|
224
|
+
return new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
|
|
225
|
+
}
|
package/src/scanner.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { loadConfig } from "./config.js";
|
|
5
|
+
import { extractDocument, isDocumentExtension } from "./documents.js";
|
|
6
|
+
import { createIgnoreMatcher, normalize } from "./ignore.js";
|
|
7
|
+
import { clonePattern, contentRules, filenameRules, severityRank } from "./rules.js";
|
|
8
|
+
|
|
9
|
+
const TEXT_EXTENSIONS = new Set([
|
|
10
|
+
"",
|
|
11
|
+
".c",
|
|
12
|
+
".cc",
|
|
13
|
+
".cfg",
|
|
14
|
+
".conf",
|
|
15
|
+
".cpp",
|
|
16
|
+
".cs",
|
|
17
|
+
".css",
|
|
18
|
+
".csv",
|
|
19
|
+
".env",
|
|
20
|
+
".go",
|
|
21
|
+
".h",
|
|
22
|
+
".hpp",
|
|
23
|
+
".html",
|
|
24
|
+
".ini",
|
|
25
|
+
".java",
|
|
26
|
+
".js",
|
|
27
|
+
".json",
|
|
28
|
+
".jsx",
|
|
29
|
+
".kt",
|
|
30
|
+
".log",
|
|
31
|
+
".md",
|
|
32
|
+
".mjs",
|
|
33
|
+
".php",
|
|
34
|
+
".properties",
|
|
35
|
+
".py",
|
|
36
|
+
".rb",
|
|
37
|
+
".rs",
|
|
38
|
+
".sh",
|
|
39
|
+
".sql",
|
|
40
|
+
".svelte",
|
|
41
|
+
".swift",
|
|
42
|
+
".toml",
|
|
43
|
+
".ts",
|
|
44
|
+
".tsx",
|
|
45
|
+
".txt",
|
|
46
|
+
".vue",
|
|
47
|
+
".xml",
|
|
48
|
+
".yaml",
|
|
49
|
+
".yml",
|
|
50
|
+
".zsh",
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
function fingerprint(ruleId, relativePath, rawValue) {
|
|
54
|
+
return crypto
|
|
55
|
+
.createHash("sha256")
|
|
56
|
+
.update(`${ruleId}\0${relativePath}\0${rawValue}`)
|
|
57
|
+
.digest("hex")
|
|
58
|
+
.slice(0, 24);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function lineAndColumn(text, offset) {
|
|
62
|
+
const before = text.slice(0, offset);
|
|
63
|
+
const lines = before.split("\n");
|
|
64
|
+
return { line: lines.length, column: lines.at(-1).length + 1 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function redactedEvidence(value) {
|
|
68
|
+
const length = [...String(value)].length;
|
|
69
|
+
return `[REDACTED ${length} chars]`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function makeFinding(rule, relativePath, rawValue = "", location = {}) {
|
|
73
|
+
return {
|
|
74
|
+
ruleId: rule.id,
|
|
75
|
+
title: rule.title,
|
|
76
|
+
severity: rule.severity,
|
|
77
|
+
kind: rule.id.startsWith("FILE_") ? "file" : "content",
|
|
78
|
+
path: relativePath,
|
|
79
|
+
line: location.line ?? 1,
|
|
80
|
+
column: location.column ?? 1,
|
|
81
|
+
message: rule.description,
|
|
82
|
+
evidence: rawValue ? redactedEvidence(rawValue) : undefined,
|
|
83
|
+
fingerprint: fingerprint(rule.id, relativePath, rawValue),
|
|
84
|
+
tags: rule.tags || [],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function ruleWithConfig(rule, config) {
|
|
89
|
+
const severity = config.severity[rule.id] || rule.severity;
|
|
90
|
+
return severity === rule.severity ? rule : { ...rule, severity };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scanText(text, relativePath, config) {
|
|
94
|
+
const findings = [];
|
|
95
|
+
for (const baseRule of contentRules) {
|
|
96
|
+
if (config.disabledRules.includes(baseRule.id)) continue;
|
|
97
|
+
const rule = ruleWithConfig(baseRule, config);
|
|
98
|
+
const pattern = clonePattern(rule.pattern);
|
|
99
|
+
let match;
|
|
100
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
101
|
+
const value = match[rule.capture || 0] || match[0];
|
|
102
|
+
if (!rule.validate || rule.validate(value, match)) {
|
|
103
|
+
const valueOffset =
|
|
104
|
+
(rule.capture || 0) > 0 ? match.index + match[0].indexOf(value) : match.index;
|
|
105
|
+
findings.push(
|
|
106
|
+
makeFinding(rule, relativePath, value, lineAndColumn(text, valueOffset)),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
if (match[0].length === 0) pattern.lastIndex += 1;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function looksBinary(buffer) {
|
|
116
|
+
const sample = buffer.subarray(0, Math.min(buffer.length, 8_192));
|
|
117
|
+
let suspicious = 0;
|
|
118
|
+
for (const byte of sample) {
|
|
119
|
+
if (byte === 0) return true;
|
|
120
|
+
if (byte < 7 || (byte > 14 && byte < 32)) suspicious += 1;
|
|
121
|
+
}
|
|
122
|
+
return sample.length > 0 && suspicious / sample.length > 0.1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function collectFiles(root, config, ignored) {
|
|
126
|
+
const files = [];
|
|
127
|
+
const warnings = [];
|
|
128
|
+
const stack = [root];
|
|
129
|
+
while (stack.length > 0 && files.length < config.maxFiles) {
|
|
130
|
+
const directory = stack.pop();
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = await fs.readdir(directory, { withFileTypes: true });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
warnings.push({ path: normalize(path.relative(root, directory)), message: error.message });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
140
|
+
for (const entry of entries) {
|
|
141
|
+
if (!config.includeHidden && entry.name.startsWith(".")) continue;
|
|
142
|
+
const absolute = path.join(directory, entry.name);
|
|
143
|
+
const relative = normalize(path.relative(root, absolute));
|
|
144
|
+
if (entry.isSymbolicLink()) continue;
|
|
145
|
+
if (ignored(relative, entry.isDirectory())) continue;
|
|
146
|
+
if (entry.isDirectory()) stack.push(absolute);
|
|
147
|
+
else if (entry.isFile()) files.push({ absolute, relative });
|
|
148
|
+
if (files.length >= config.maxFiles) break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (files.length >= config.maxFiles) {
|
|
152
|
+
warnings.push({
|
|
153
|
+
path: ".",
|
|
154
|
+
message: `Stopped after maxFiles=${config.maxFiles}. Results may be incomplete.`,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return { files, warnings };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function scanFile(file, config) {
|
|
161
|
+
const findings = [];
|
|
162
|
+
for (const baseRule of filenameRules) {
|
|
163
|
+
if (config.disabledRules.includes(baseRule.id)) continue;
|
|
164
|
+
const rule = ruleWithConfig(baseRule, config);
|
|
165
|
+
if (rule.pattern.test(file.relative)) findings.push(makeFinding(rule, file.relative));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let stats;
|
|
169
|
+
try {
|
|
170
|
+
stats = await fs.stat(file.absolute);
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return { findings, warning: { path: file.relative, message: error.message } };
|
|
173
|
+
}
|
|
174
|
+
if (stats.size > config.maxFileSize) {
|
|
175
|
+
return {
|
|
176
|
+
findings,
|
|
177
|
+
skipped: { path: file.relative, reason: "max-file-size", size: stats.size },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const extension = path.extname(file.absolute).toLowerCase();
|
|
182
|
+
if (config.documents && isDocumentExtension(extension)) {
|
|
183
|
+
const document = await extractDocument(file.absolute, config);
|
|
184
|
+
if (document?.inspected) findings.push(...scanText(document.text, file.relative, config));
|
|
185
|
+
if (document && !document.inspected) {
|
|
186
|
+
return {
|
|
187
|
+
findings,
|
|
188
|
+
inspected: false,
|
|
189
|
+
skipped: {
|
|
190
|
+
path: file.relative,
|
|
191
|
+
reason: `document-uninspected:${document.extractor}`,
|
|
192
|
+
size: stats.size,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
return { findings, inspected: document?.inspected ?? false };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (!TEXT_EXTENSIONS.has(extension)) return { findings, inspected: false };
|
|
200
|
+
let buffer;
|
|
201
|
+
try {
|
|
202
|
+
buffer = await fs.readFile(file.absolute);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
return { findings, warning: { path: file.relative, message: error.message } };
|
|
205
|
+
}
|
|
206
|
+
if (looksBinary(buffer)) return { findings, inspected: false };
|
|
207
|
+
findings.push(...scanText(buffer.toString("utf8"), file.relative, config));
|
|
208
|
+
return { findings, inspected: true };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function summarize(findings) {
|
|
212
|
+
const bySeverity = { critical: 0, high: 0, medium: 0, low: 0 };
|
|
213
|
+
const byRule = {};
|
|
214
|
+
for (const finding of findings) {
|
|
215
|
+
bySeverity[finding.severity] = (bySeverity[finding.severity] || 0) + 1;
|
|
216
|
+
byRule[finding.ruleId] = (byRule[finding.ruleId] || 0) + 1;
|
|
217
|
+
}
|
|
218
|
+
return { total: findings.length, bySeverity, byRule };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function scan(target = ".", options = {}) {
|
|
222
|
+
const root = path.resolve(target);
|
|
223
|
+
const rootStats = await fs.stat(root);
|
|
224
|
+
if (!rootStats.isDirectory()) throw new Error(`Scan target is not a directory: ${root}`);
|
|
225
|
+
const { config: fileConfig, configPath } = await loadConfig(root, options.configPath);
|
|
226
|
+
const providedOptions = Object.fromEntries(
|
|
227
|
+
Object.entries(options).filter(([, value]) => value !== undefined),
|
|
228
|
+
);
|
|
229
|
+
const config = {
|
|
230
|
+
...fileConfig,
|
|
231
|
+
...providedOptions,
|
|
232
|
+
ignore: providedOptions.ignore
|
|
233
|
+
? [...fileConfig.ignore, ...providedOptions.ignore]
|
|
234
|
+
: fileConfig.ignore,
|
|
235
|
+
severity: { ...fileConfig.severity, ...(providedOptions.severity || {}) },
|
|
236
|
+
};
|
|
237
|
+
delete config.configPath;
|
|
238
|
+
|
|
239
|
+
const ignored = await createIgnoreMatcher(root, config);
|
|
240
|
+
const started = Date.now();
|
|
241
|
+
const { files, warnings } = await collectFiles(root, config, ignored);
|
|
242
|
+
const findings = [];
|
|
243
|
+
const skipped = [];
|
|
244
|
+
let inspectedFiles = 0;
|
|
245
|
+
|
|
246
|
+
const concurrency = Math.max(1, Math.min(Number(options.concurrency) || 8, 32));
|
|
247
|
+
let cursor = 0;
|
|
248
|
+
async function worker() {
|
|
249
|
+
while (cursor < files.length) {
|
|
250
|
+
const file = files[cursor];
|
|
251
|
+
cursor += 1;
|
|
252
|
+
const result = await scanFile(file, config);
|
|
253
|
+
findings.push(...result.findings);
|
|
254
|
+
if (result.warning) warnings.push(result.warning);
|
|
255
|
+
if (result.skipped) skipped.push(result.skipped);
|
|
256
|
+
if (result.inspected) inspectedFiles += 1;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
260
|
+
|
|
261
|
+
findings.sort(
|
|
262
|
+
(a, b) =>
|
|
263
|
+
severityRank[a.severity] - severityRank[b.severity] ||
|
|
264
|
+
a.path.localeCompare(b.path) ||
|
|
265
|
+
a.line - b.line ||
|
|
266
|
+
a.ruleId.localeCompare(b.ruleId),
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
schemaVersion: "1.0",
|
|
271
|
+
tool: { name: "context-armor", version: options.toolVersion || "0.1.0" },
|
|
272
|
+
root,
|
|
273
|
+
configPath,
|
|
274
|
+
policy: { failOn: config.failOn },
|
|
275
|
+
scannedAt: new Date().toISOString(),
|
|
276
|
+
durationMs: Date.now() - started,
|
|
277
|
+
files: {
|
|
278
|
+
discovered: files.length,
|
|
279
|
+
inspected: inspectedFiles,
|
|
280
|
+
skipped: skipped.length,
|
|
281
|
+
},
|
|
282
|
+
findings,
|
|
283
|
+
summary: summarize(findings),
|
|
284
|
+
warnings,
|
|
285
|
+
skipped,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export { scanText, summarize };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export function passesLuhn(value) {
|
|
2
|
+
const digits = String(value).replace(/\D/g, "");
|
|
3
|
+
if (digits.length < 13 || digits.length > 19 || /^(\d)\1+$/.test(digits)) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let sum = 0;
|
|
8
|
+
let doubleDigit = false;
|
|
9
|
+
for (let index = digits.length - 1; index >= 0; index -= 1) {
|
|
10
|
+
let digit = Number(digits[index]);
|
|
11
|
+
if (doubleDigit) {
|
|
12
|
+
digit *= 2;
|
|
13
|
+
if (digit > 9) digit -= 9;
|
|
14
|
+
}
|
|
15
|
+
sum += digit;
|
|
16
|
+
doubleDigit = !doubleDigit;
|
|
17
|
+
}
|
|
18
|
+
return sum % 10 === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isValidKoreanRrn(value) {
|
|
22
|
+
const digits = String(value).replace(/\D/g, "");
|
|
23
|
+
if (digits.length !== 13 || /^(\d)\1+$/.test(digits)) return false;
|
|
24
|
+
|
|
25
|
+
const month = Number(digits.slice(2, 4));
|
|
26
|
+
const day = Number(digits.slice(4, 6));
|
|
27
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return false;
|
|
28
|
+
|
|
29
|
+
const weights = [2, 3, 4, 5, 6, 7, 8, 9, 2, 3, 4, 5];
|
|
30
|
+
const weighted = weights.reduce(
|
|
31
|
+
(total, weight, index) => total + Number(digits[index]) * weight,
|
|
32
|
+
0,
|
|
33
|
+
);
|
|
34
|
+
const legacyCheck = (11 - (weighted % 11)) % 10;
|
|
35
|
+
return legacyCheck === Number(digits[12]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isLikelyPlaceholder(value) {
|
|
39
|
+
const normalized = String(value).trim().toLowerCase();
|
|
40
|
+
return (
|
|
41
|
+
normalized.length === 0 ||
|
|
42
|
+
/^(example|sample|test|dummy|placeholder|changeme|your[_-]|xxx|todo|null|undefined)/.test(
|
|
43
|
+
normalized,
|
|
44
|
+
) ||
|
|
45
|
+
/^\$\{[^}]+\}$/.test(normalized) ||
|
|
46
|
+
/^<[^>]+>$/.test(normalized)
|
|
47
|
+
);
|
|
48
|
+
}
|