@blundergoat/gruff-ts 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/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Sensitive-data scanners and redaction helpers for secret-like raw text findings.
|
|
2
|
+
import { ruleSeverity, threshold } from "./config.ts";
|
|
3
|
+
import { makeFinding } from "./findings.ts";
|
|
4
|
+
import { byteLine } from "./text-scans.ts";
|
|
5
|
+
import type { Config, Finding } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
// Just the display path - sensitive-data rules anchor findings on file path + line and never need
|
|
8
|
+
// the absolute path. Keeping this trimmed keeps the contract narrow for testability.
|
|
9
|
+
interface SensitiveSourceFile {
|
|
10
|
+
displayPath: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Pillar entry point. The pattern array order is the deterministic emission order for findings,
|
|
14
|
+
// which the fingerprint contract depends on - reordering would churn baselines without behaviour change.
|
|
15
|
+
function analyseSensitiveData(file: SensitiveSourceFile, source: string, config: Config, findings: Finding[]): void {
|
|
16
|
+
const patterns: Array<[string, RegExp, string]> = [
|
|
17
|
+
["sensitive-data.aws-access-key", /AKIA[0-9A-Z]{16}/g, "AWS access key pattern detected."],
|
|
18
|
+
["sensitive-data.private-key", /BEGIN (RSA |OPENSSH |EC |DSA )?PRIVATE KEY/g, "Private key block detected."],
|
|
19
|
+
["sensitive-data.jwt-token", /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, "JWT-looking token detected."],
|
|
20
|
+
["sensitive-data.database-url-password", /\b(?:postgres(?:ql)?|mysql|mariadb|mongodb(?:\+srv)?|redis|amqp|amqps|mssql):\/\/[^\/\s:@]+:[^\/\s@]+@/g, "Database URL appears to include a password."],
|
|
21
|
+
[
|
|
22
|
+
"sensitive-data.api-key-pattern",
|
|
23
|
+
/\b(?:sk_live_[A-Za-z0-9_-]{12,}|sk_test_[A-Za-z0-9_-]{12,}|sk-proj-[A-Za-z0-9_-]{16,}|sk-ant-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gh[ousr]_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|npm_[A-Za-z0-9]{20,}|AIza[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+|https:\/\/discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+)\b/g,
|
|
24
|
+
"API key pattern detected.",
|
|
25
|
+
],
|
|
26
|
+
["sensitive-data.pii-pattern", /\b\d{3}-\d{2}-\d{4}\b/g, "PII-like identifier pattern detected."],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
for (const [ruleId, pattern, message] of patterns) {
|
|
30
|
+
for (const match of source.matchAll(pattern)) {
|
|
31
|
+
const raw = match[0] ?? "";
|
|
32
|
+
pushSensitiveFinding(config, findings, file, ruleId, message, byteLine(source, match.index ?? 0), raw, "high");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
analyseHardcodedEnvironmentValues(file, source, config, findings);
|
|
37
|
+
analyseNpmAuthTokens(file, source, config, findings);
|
|
38
|
+
analyseHighEntropyStrings(file, source, config, findings);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Stable redaction contract: parses `_authToken=` config lines even when values lack an npm_ prefix.
|
|
42
|
+
function analyseNpmAuthTokens(file: SensitiveSourceFile, source: string, config: Config, findings: Finding[]): void {
|
|
43
|
+
const lines = source.split(/\r?\n/);
|
|
44
|
+
for (const [index, line] of lines.entries()) {
|
|
45
|
+
const token = npmAuthTokenValue(line);
|
|
46
|
+
if (!token) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
pushSensitiveFinding(
|
|
50
|
+
config,
|
|
51
|
+
findings,
|
|
52
|
+
file,
|
|
53
|
+
"sensitive-data.api-key-pattern",
|
|
54
|
+
"npm auth token pattern detected.",
|
|
55
|
+
index + 1,
|
|
56
|
+
token,
|
|
57
|
+
"high",
|
|
58
|
+
{ keyName: "_authToken" },
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Extracts the token portion from scoped or registry-prefixed npm auth config lines.
|
|
64
|
+
function npmAuthTokenValue(line: string): string | undefined {
|
|
65
|
+
const match = line.match(/(?:^|:)_authToken\s*=\s*([A-Za-z0-9_-]{20,})\b/);
|
|
66
|
+
return match?.[1];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Targets `KEY=value` and `KEY: value` assignments where the key name signals secrets (API_KEY,
|
|
70
|
+
// TOKEN, PASSWORD…). The `minLength` threshold keeps short fixture values like `PLACEHOLDER`
|
|
71
|
+
// from churning the baseline - it is part of the rule's stable, deterministic contract.
|
|
72
|
+
function analyseHardcodedEnvironmentValues(file: SensitiveSourceFile, source: string, config: Config, findings: Finding[]): void {
|
|
73
|
+
const minLength = threshold(config, "sensitive-data.hardcoded-env-value", 16);
|
|
74
|
+
const lines = source.split(/\r?\n/);
|
|
75
|
+
for (const [index, line] of lines.entries()) {
|
|
76
|
+
const envValue = hardcodedEnvValue(line, minLength);
|
|
77
|
+
if (!envValue) {
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
pushSensitiveFinding(
|
|
81
|
+
config,
|
|
82
|
+
findings,
|
|
83
|
+
file,
|
|
84
|
+
"sensitive-data.hardcoded-env-value",
|
|
85
|
+
`Environment-style value \`${envValue.keyName}\` appears to be hardcoded with secret-like content.`,
|
|
86
|
+
index + 1,
|
|
87
|
+
envValue.value,
|
|
88
|
+
"medium",
|
|
89
|
+
{ keyName: envValue.keyName, length: envValue.value.length },
|
|
90
|
+
ruleSeverity(config, "sensitive-data.hardcoded-env-value", "error"),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Shannon-entropy detector with three guardrails (length, case diversity, distinct characters)
|
|
96
|
+
// because plain entropy alone fires on package-lock SRI hashes; the layered checks keep findings
|
|
97
|
+
// stable across runs and prevent noisy regressions in node_modules-heavy projects.
|
|
98
|
+
function analyseHighEntropyStrings(file: SensitiveSourceFile, source: string, config: Config, findings: Finding[]): void {
|
|
99
|
+
const minLength = threshold(config, "sensitive-data.high-entropy-string", 32);
|
|
100
|
+
for (const match of source.matchAll(/(["'`])([A-Za-z0-9_+=./-]{32,})\1/g)) {
|
|
101
|
+
const raw = match[2] ?? "";
|
|
102
|
+
if (!isHighEntropySecretCandidate(raw, minLength)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
pushSensitiveFinding(
|
|
106
|
+
config,
|
|
107
|
+
findings,
|
|
108
|
+
file,
|
|
109
|
+
"sensitive-data.high-entropy-string",
|
|
110
|
+
"High-entropy string literal may be an embedded secret.",
|
|
111
|
+
byteLine(source, match.index ?? 0),
|
|
112
|
+
raw,
|
|
113
|
+
"medium",
|
|
114
|
+
{ length: raw.length, detector: "high-entropy-string" },
|
|
115
|
+
ruleSeverity(config, "sensitive-data.high-entropy-string", "error"),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pushSensitiveFinding(
|
|
121
|
+
config: Config,
|
|
122
|
+
findings: Finding[],
|
|
123
|
+
file: SensitiveSourceFile,
|
|
124
|
+
ruleId: string,
|
|
125
|
+
message: string,
|
|
126
|
+
line: number,
|
|
127
|
+
raw: string,
|
|
128
|
+
confidence: Finding["confidence"],
|
|
129
|
+
metadata: Record<string, unknown> = {},
|
|
130
|
+
severity: Finding["severity"] = "error",
|
|
131
|
+
): void {
|
|
132
|
+
const preview = redact(raw);
|
|
133
|
+
if (config.secretPreviews.has(preview)) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
findings.push(
|
|
137
|
+
makeFinding({
|
|
138
|
+
ruleId,
|
|
139
|
+
message: `${message} Redacted preview: ${preview}.`,
|
|
140
|
+
filePath: file.displayPath,
|
|
141
|
+
line,
|
|
142
|
+
severity,
|
|
143
|
+
pillar: "sensitive-data",
|
|
144
|
+
confidence,
|
|
145
|
+
remediation: "Remove the sensitive value and load it from a secure runtime source.",
|
|
146
|
+
metadata: { ...metadata, preview },
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Two-stage filter: parse the line into a (key, value) pair, then apply the secret-shape filters.
|
|
152
|
+
// Splitting them keeps the regex simple - the line shape is shared, only the value test changes.
|
|
153
|
+
function hardcodedEnvValue(line: string, minLength: number): { keyName: string; value: string } | undefined {
|
|
154
|
+
const candidate = envValueCandidate(line);
|
|
155
|
+
if (!candidate || !isHardcodedEnvCandidate(candidate.value, minLength)) {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return candidate;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Matches the documented secret-key vocabulary (API_KEY, TOKEN, SECRET, PASSWORD, DATABASE_URL,
|
|
162
|
+
// DSN, CREDENTIAL). Expanding this list will widen sensitive-data coverage - keep it intentional.
|
|
163
|
+
function envValueCandidate(line: string): { keyName: string; value: string } | undefined {
|
|
164
|
+
const match = line.match(/^\s*((?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|DATABASE_URL|DSN)|[A-Z][A-Z0-9_-]*(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|DATABASE_URL|DSN)[A-Z0-9_-]*)\s*[:=]\s*["']?([^"'\s#]+)["']?/i);
|
|
165
|
+
const keyName = match?.[1] ?? "";
|
|
166
|
+
const secretValue = match?.[2] ?? "";
|
|
167
|
+
if (!keyName) {
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
return { keyName, value: secretValue };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Three predicates combined: long enough, not a literal placeholder, and shape-like (letters + digits).
|
|
174
|
+
// All three are required - dropping any one regresses to noisy findings on fixture values.
|
|
175
|
+
function isHardcodedEnvCandidate(secretValue: string, minLength: number): boolean {
|
|
176
|
+
return secretValue.length >= minLength && !isPlaceholderSecretValue(secretValue) && hasLetterAndDigit(secretValue);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Allowlist of obvious fixture words. Case-insensitive so `Placeholder`, `PASSWORD` and similar
|
|
180
|
+
// fixture values stay quiet. Extend deliberately - the cost of a missing word is a false positive.
|
|
181
|
+
function isPlaceholderSecretValue(secretValue: string): boolean {
|
|
182
|
+
return /^(?:x-api-key|token|secret|password|example|sample|placeholder)$/i.test(secretValue);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Cheap shape filter: rejects all-letters words and all-digit numbers before the more expensive
|
|
186
|
+
// entropy work runs. False negatives here are acceptable; false positives waste maintainer time.
|
|
187
|
+
function hasLetterAndDigit(candidateText: string): boolean {
|
|
188
|
+
return /[A-Za-z]/.test(candidateText) && /[0-9]/.test(candidateText);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Layered filter for the entropy detector. Order matters: cheap rejections (length, hex digest,
|
|
192
|
+
// SRI hash) run before character-set checks before the entropy calculation itself, which is the
|
|
193
|
+
// most expensive step. Reordering changes nothing semantically but can regress scan performance.
|
|
194
|
+
function isHighEntropySecretCandidate(candidateText: string, minLength: number): boolean {
|
|
195
|
+
if (isExcludedHighEntropyCandidate(candidateText, minLength)) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
if (!hasLowerUpperAndDigit(candidateText)) {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (!hasEnoughDistinctCharacters(candidateText)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
return shannonEntropy(candidateText) >= 4;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Entropy false-positive escape hatches. Hex digests and SRI hashes both look "high entropy" but
|
|
208
|
+
// are well-known non-secrets - without these exclusions, package-lock.json scans become noise.
|
|
209
|
+
// Repo-relative path-shaped strings (slashes plus a known extension and a conventional prefix
|
|
210
|
+
// segment) also clear the entropy bar without being secrets; the path-shape guard suppresses them.
|
|
211
|
+
function isExcludedHighEntropyCandidate(candidateText: string, minLength: number): boolean {
|
|
212
|
+
return candidateText.length < minLength || isHexDigest(candidateText) || isSubresourceIntegrityHash(candidateText) || isRepoPathShape(candidateText);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Path-shape guard: a string that contains at least one `/`, has a path-like extension, AND lives
|
|
216
|
+
// under a conventional source/docs/test/workflow prefix is almost always a repo-relative reference
|
|
217
|
+
// rather than a secret. The combined gate keeps the rule firing on real high-entropy material that
|
|
218
|
+
// happens to contain slashes.
|
|
219
|
+
function isRepoPathShape(candidateText: string): boolean {
|
|
220
|
+
const normalized = candidateText.replaceAll("\\", "/");
|
|
221
|
+
const hasKnownExtension = /\.(?:md|mdx|ts|tsx|js|jsx|mjs|cjs|json|yaml|yml|toml|html|css|svg|sh)$/.test(candidateText);
|
|
222
|
+
if (!hasKnownExtension) {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
if (!normalized.includes("/")) {
|
|
226
|
+
return isRepoPathLikeFilename(normalized);
|
|
227
|
+
}
|
|
228
|
+
return hasKnownRepoPathSegment(normalized);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Basename-only docs and milestone filenames such as `ADR-024-...md` and `M00-...md`
|
|
232
|
+
// appear in fixtures without a directory prefix but are still path references, not secrets.
|
|
233
|
+
function isRepoPathLikeFilename(candidateText: string): boolean {
|
|
234
|
+
return /^(?:ADR-\d{3}|M\d{2,3})-[A-Za-z0-9_.-]+\.(?:md|mdx)$/i.test(candidateText);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Path references may be repo-relative under source directories or fixture-absolute under `/repo`.
|
|
238
|
+
// Requiring a known repository segment keeps slash-containing tokens from being over-suppressed.
|
|
239
|
+
function hasKnownRepoPathSegment(candidateText: string): boolean {
|
|
240
|
+
return /(?:^|\/)(?:\.goat-flow|src|test|tests|fixtures?|docs|scripts|bin|workflow|package(?:-lock)?\.json)(?:\/|$)/.test(candidateText);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// All-hex strings - typical for SHA digests, content hashes, and tooling identifiers.
|
|
244
|
+
function isHexDigest(candidateText: string): boolean {
|
|
245
|
+
return /^[0-9a-f]+$/i.test(candidateText);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// SRI hashes from `package-lock.json` and `<script integrity=...>` attributes. Excluded so the
|
|
249
|
+
// detector stays quiet on dependency manifests.
|
|
250
|
+
function isSubresourceIntegrityHash(candidateText: string): boolean {
|
|
251
|
+
return /^sha(?:256|384|512)-[A-Za-z0-9+/=]+$/.test(candidateText);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Three-class character requirement that filters out single-case identifiers and pure base64 hashes
|
|
255
|
+
// before the entropy calculation runs. Real API tokens almost always contain all three classes.
|
|
256
|
+
function hasLowerUpperAndDigit(candidateText: string): boolean {
|
|
257
|
+
return /[a-z]/.test(candidateText) && /[A-Z]/.test(candidateText) && /[0-9]/.test(candidateText);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Distinct-character guard. The cap at 12 keeps a long alphabet from inflating the requirement;
|
|
261
|
+
// the `ceil(length/3)` floor scales the threshold with the candidate length.
|
|
262
|
+
function hasEnoughDistinctCharacters(candidateText: string): boolean {
|
|
263
|
+
return new Set(candidateText).size >= Math.min(12, Math.ceil(candidateText.length / 3));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Standard Shannon entropy in bits per symbol. The 4.0-bit threshold in the caller corresponds
|
|
267
|
+
// roughly to a uniform alphabet of 16 distinct characters - the typical floor for real secrets.
|
|
268
|
+
function shannonEntropy(candidateText: string): number {
|
|
269
|
+
const counts = new Map<string, number>();
|
|
270
|
+
for (const character of candidateText) {
|
|
271
|
+
counts.set(character, (counts.get(character) ?? 0) + 1);
|
|
272
|
+
}
|
|
273
|
+
return [...counts.values()].reduce((sum, count) => {
|
|
274
|
+
const probability = count / candidateText.length;
|
|
275
|
+
return sum - probability * Math.log2(probability);
|
|
276
|
+
}, 0);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Preview format used in finding messages. Short values are fully masked; longer values show only
|
|
280
|
+
// the first/last 4 characters so an operator can identify which secret to rotate without leaking it.
|
|
281
|
+
function redact(rawSecret: string): string {
|
|
282
|
+
if (rawSecret.length <= 8) {
|
|
283
|
+
return `${"*".repeat(rawSecret.length)} (redacted, ${rawSecret.length} chars)`;
|
|
284
|
+
}
|
|
285
|
+
return `${rawSecret.slice(0, 4)}...${rawSecret.slice(-4)} (redacted, ${rawSecret.length} chars)`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export { analyseSensitiveData };
|