@ericsanchezok/synergy-secret-detection 0.0.0-stage → 3.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/README.md +56 -2
- package/dist/detector.d.ts +56 -0
- package/dist/detector.js +89 -0
- package/dist/evaluation.d.ts +112 -0
- package/dist/evaluation.js +132 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/patterns.d.ts +25 -0
- package/dist/patterns.js +70 -0
- package/dist/regex.d.ts +2 -0
- package/dist/regex.js +27 -0
- package/package.json +32 -4
- package/src/detector.ts +101 -0
- package/src/evaluation.ts +166 -0
- package/src/index.ts +2 -0
- package/src/patterns.ts +69 -0
- package/src/regex.ts +27 -0
package/README.md
CHANGED
|
@@ -1,3 +1,57 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Secret detection
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Runtime-independent detection and evaluation for Synergy. The package exports a typed asynchronous detector, the `standalone-regex` version 2 baseline, shared redaction patterns and evaluation functions. It does not read credentials, access the filesystem, register secrets, load models or make network requests. Executable evaluation scripts own their input/output separately.
|
|
4
|
+
|
|
5
|
+
## Detector contract
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { RegexDetector, SecretDetection } from "@ericsanchezok/synergy-secret-detection"
|
|
9
|
+
|
|
10
|
+
const result = await SecretDetection.detect(
|
|
11
|
+
RegexDetector,
|
|
12
|
+
{
|
|
13
|
+
text: "original message or tool output",
|
|
14
|
+
source: "user_message",
|
|
15
|
+
},
|
|
16
|
+
{ timeoutMs: 5000 },
|
|
17
|
+
)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
`Detector` has `id`, `version` and `detect({ text, source, signal })`. A successful scan returns `{ complete: true, findings }`; a truncated or unsupported scan returns `{ complete: false, reason, findings }`. Each finding has `start`, `end`, `kind` and optional `score`. Offsets are zero-based UTF-16 code units into the exact input, with an inclusive start and exclusive end. Findings must be sorted, non-overlapping, non-empty, within the input and outside surrogate pairs. Consumers extract `text.slice(start, end)`; adapters never regenerate the secret value. A score is adapter-specific and is not assumed calibrated across implementations.
|
|
21
|
+
|
|
22
|
+
The runner validates results and exposes sanitized `SecretDetection.Error` codes for failure, invalid output, cancellation and timeout. Cancellation is cooperative: it forwards an `AbortSignal` and bounds the caller's wait, but cannot preempt synchronous JavaScript or forcibly stop a model process. A model adapter must honor cancellation and own loading/disposal outside individual detection calls; its version must identify weights, preprocessing, thresholds and other effective settings. No model adapter ships in this package.
|
|
23
|
+
|
|
24
|
+
The regex baseline scans the whole input with the shared standalone prefix inventory and OAuth authorization-code URL spans, then merges overlapping hits. Authorization codes require an `auth`, `oauth`, `oauth2`, `authorize` or `callback` path segment; ordinary document URL `code` parameters remain ordinary text. It does not parse arbitrary credential fields, measure entropy, validate credentials or use the separate log/SmartAllow rules. Example keys can be false positives; unknown formats can be false negatives. These cases are retained in the evaluation corpus.
|
|
25
|
+
|
|
26
|
+
## Harness integration
|
|
27
|
+
|
|
28
|
+
The Harness `secrets/detector-source` entry accepts `SecretDetectorSource.register(detector)` during runtime composition; `register(undefined)` restores the regex default. Harness owns capture, Vault persistence, masking and execution-time resolution. An empty detection result does not disable matching already registered values. Incomplete scans, timeouts, invalid results and registration failures stop the capture operation instead of being treated as clean text. The production capture deadline is five seconds.
|
|
29
|
+
|
|
30
|
+
Harness retains its eight-character minimum for global value replacement. An adapter returning a shorter candidate is rejected before registration rather than reporting success while leaving it unmasked. Offline evaluation supports shorter spans independently. Changing this host policy needs separate false-positive and masking evaluation.
|
|
31
|
+
|
|
32
|
+
## Evaluation commands
|
|
33
|
+
|
|
34
|
+
Run from this package:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bun test
|
|
38
|
+
bun run typecheck
|
|
39
|
+
bun run evaluate --output results/regex.json
|
|
40
|
+
bun run evaluate --detector /absolute/path/to/adapter.ts --dataset fixtures/corpus.json --output results/candidate.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
An adapter module exports `detector: SecretDetection.Detector`. It is explicitly selected executable code; the runner does not scan for plugins or instantiate a model SDK. `--samples` defaults to 100, `--warmup` to 20, and `--timeout-ms` to 5000. `--help` lists all options.
|
|
44
|
+
|
|
45
|
+
[The synthetic corpus](fixtures/corpus.json) has independent original-text span labels, recognized prefixes, unknown credentials, Unicode, JSON, URLs, multiple hits, ordinary code, hashes and benign example keys. These deliberately small fixtures validate the evaluator and expose baseline gaps; they are not a representative quality benchmark or training set. Future training and holdout splits must separate source/template and credential families, rather than only changing random key bytes.
|
|
46
|
+
|
|
47
|
+
Reports include detector/version, dataset version/hash, evaluator source hash, optional adapter module hash, runtime/hardware, per-case outcomes and aggregate/group metrics. Gold labels are never passed to detectors. Exact precision/recall score span boundaries; full-coverage recall requires a finding to contain the whole labeled secret, so a prefix-only hit fails protection. Clean-message false-positive rate uses only completed negative cases; failed/incomplete negatives are reported separately. Failed positive cases remain in recall denominators. Null ratios mean no denominator, not perfect quality. Reports omit sample text, detected values and raw exception messages; use non-sensitive case identifiers and adapter metadata too.
|
|
48
|
+
|
|
49
|
+
Timing scenarios use synthetic 1 KiB, 64 KiB and 1 MiB inputs with 0, 1 or 32 candidates. They measure the detector plus asynchronous result validation, first-call time per scenario after accuracy evaluation, warm p50/p95, throughput, process CPU and RSS snapshots. Adapter import and first-call times are not full process cold-start or peak-memory measurements. Failed/incomplete scenarios remain visible and produce a nonzero command exit. Misses and false positives are reported metrics, not infrastructure failures.
|
|
50
|
+
|
|
51
|
+
The real masking pipeline has a separate isolated runner:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
bun run --cwd packages/harness benchmark:secrets --samples 20
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Run that command from the repository root. It varies Vault size, input length, hit count and first-registration versus already-registered paths. Each run owns and removes a temporary home. It includes detection, validation, Vault and replacement, excludes fixture resets from latency, and never starts a server or calls a model. Use [the task benchmark](../../benchmark/README.md) for session-level latency and task outcomes; detector microbenchmarks cannot establish those effects.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare namespace SecretDetection {
|
|
3
|
+
const Source: z.ZodEnum<{
|
|
4
|
+
credential_file: "credential_file";
|
|
5
|
+
tool_output: "tool_output";
|
|
6
|
+
user_message: "user_message";
|
|
7
|
+
}>;
|
|
8
|
+
type Source = z.infer<typeof Source>;
|
|
9
|
+
const Finding: z.ZodObject<{
|
|
10
|
+
start: z.ZodNumber;
|
|
11
|
+
end: z.ZodNumber;
|
|
12
|
+
kind: z.ZodString;
|
|
13
|
+
score: z.ZodOptional<z.ZodNumber>;
|
|
14
|
+
}, z.core.$strip>;
|
|
15
|
+
type Finding = z.infer<typeof Finding>;
|
|
16
|
+
const Result: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
17
|
+
complete: z.ZodLiteral<true>;
|
|
18
|
+
findings: z.ZodArray<z.ZodObject<{
|
|
19
|
+
start: z.ZodNumber;
|
|
20
|
+
end: z.ZodNumber;
|
|
21
|
+
kind: z.ZodString;
|
|
22
|
+
score: z.ZodOptional<z.ZodNumber>;
|
|
23
|
+
}, z.core.$strip>>;
|
|
24
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
25
|
+
complete: z.ZodLiteral<false>;
|
|
26
|
+
findings: z.ZodArray<z.ZodObject<{
|
|
27
|
+
start: z.ZodNumber;
|
|
28
|
+
end: z.ZodNumber;
|
|
29
|
+
kind: z.ZodString;
|
|
30
|
+
score: z.ZodOptional<z.ZodNumber>;
|
|
31
|
+
}, z.core.$strip>>;
|
|
32
|
+
reason: z.ZodEnum<{
|
|
33
|
+
input_limit: "input_limit";
|
|
34
|
+
unsupported: "unsupported";
|
|
35
|
+
}>;
|
|
36
|
+
}, z.core.$strip>], "complete">;
|
|
37
|
+
type Result = z.infer<typeof Result>;
|
|
38
|
+
interface Input {
|
|
39
|
+
text: string;
|
|
40
|
+
source: Source;
|
|
41
|
+
signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
interface Detector {
|
|
44
|
+
readonly id: string;
|
|
45
|
+
readonly version: string;
|
|
46
|
+
detect(input: Input): Promise<Result>;
|
|
47
|
+
}
|
|
48
|
+
class Error extends globalThis.Error {
|
|
49
|
+
readonly code: "aborted" | "timeout" | "invalid_result" | "detector_failed" | "incomplete";
|
|
50
|
+
constructor(code: "aborted" | "timeout" | "invalid_result" | "detector_failed" | "incomplete");
|
|
51
|
+
}
|
|
52
|
+
function validate(text: string, result: unknown): Result;
|
|
53
|
+
function detect(detector: Detector, input: Input, options?: {
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
}): Promise<Result>;
|
|
56
|
+
}
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export var SecretDetection;
|
|
3
|
+
(function (SecretDetection) {
|
|
4
|
+
SecretDetection.Source = z.enum(["user_message", "tool_output", "credential_file"]);
|
|
5
|
+
SecretDetection.Finding = z.object({
|
|
6
|
+
start: z.number().int().nonnegative(),
|
|
7
|
+
end: z.number().int().positive(),
|
|
8
|
+
kind: z.string().min(1),
|
|
9
|
+
score: z.number().min(0).max(1).optional(),
|
|
10
|
+
});
|
|
11
|
+
SecretDetection.Result = z.discriminatedUnion("complete", [
|
|
12
|
+
z.object({ complete: z.literal(true), findings: z.array(SecretDetection.Finding) }),
|
|
13
|
+
z.object({
|
|
14
|
+
complete: z.literal(false),
|
|
15
|
+
findings: z.array(SecretDetection.Finding),
|
|
16
|
+
reason: z.enum(["input_limit", "unsupported"]),
|
|
17
|
+
}),
|
|
18
|
+
]);
|
|
19
|
+
class Error extends globalThis.Error {
|
|
20
|
+
code;
|
|
21
|
+
constructor(code) {
|
|
22
|
+
super(`Secret detection failed: ${code}`);
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.name = "SecretDetectionError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
SecretDetection.Error = Error;
|
|
28
|
+
function splitsSurrogate(text, offset) {
|
|
29
|
+
const left = text.charCodeAt(offset - 1);
|
|
30
|
+
const right = text.charCodeAt(offset);
|
|
31
|
+
return left >= 0xd800 && left <= 0xdbff && right >= 0xdc00 && right <= 0xdfff;
|
|
32
|
+
}
|
|
33
|
+
function validate(text, result) {
|
|
34
|
+
const parsed = SecretDetection.Result.safeParse(result);
|
|
35
|
+
if (!parsed.success)
|
|
36
|
+
throw new Error("invalid_result");
|
|
37
|
+
let end = 0;
|
|
38
|
+
for (const finding of parsed.data.findings) {
|
|
39
|
+
if (finding.start < end ||
|
|
40
|
+
finding.end <= finding.start ||
|
|
41
|
+
finding.end > text.length ||
|
|
42
|
+
splitsSurrogate(text, finding.start) ||
|
|
43
|
+
splitsSurrogate(text, finding.end))
|
|
44
|
+
throw new Error("invalid_result");
|
|
45
|
+
end = finding.end;
|
|
46
|
+
}
|
|
47
|
+
return parsed.data;
|
|
48
|
+
}
|
|
49
|
+
SecretDetection.validate = validate;
|
|
50
|
+
async function detect(detector, input, options = {}) {
|
|
51
|
+
if (input.signal?.aborted)
|
|
52
|
+
throw new Error("aborted");
|
|
53
|
+
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
|
54
|
+
throw new RangeError("timeoutMs must be a positive finite number");
|
|
55
|
+
}
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
let rejectAbort;
|
|
58
|
+
const cancelled = new Promise((_, reject) => {
|
|
59
|
+
rejectAbort = reject;
|
|
60
|
+
});
|
|
61
|
+
const cancel = (code) => {
|
|
62
|
+
rejectAbort(new Error(code));
|
|
63
|
+
controller.abort();
|
|
64
|
+
};
|
|
65
|
+
const onAbort = () => cancel("aborted");
|
|
66
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
67
|
+
const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => cancel("timeout"), options.timeoutMs);
|
|
68
|
+
try {
|
|
69
|
+
const result = await Promise.race([
|
|
70
|
+
Promise.resolve().then(() => detector.detect({ text: input.text, source: input.source, signal: controller.signal })),
|
|
71
|
+
cancelled,
|
|
72
|
+
]);
|
|
73
|
+
if (input.signal?.aborted)
|
|
74
|
+
throw new Error("aborted");
|
|
75
|
+
return validate(input.text, result);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
if (error instanceof Error)
|
|
79
|
+
throw error;
|
|
80
|
+
throw new Error("detector_failed");
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (timer !== undefined)
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
SecretDetection.detect = detect;
|
|
89
|
+
})(SecretDetection || (SecretDetection = {}));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SecretDetection } from "./detector.js";
|
|
3
|
+
export declare namespace SecretEvaluation {
|
|
4
|
+
export const Case: z.ZodObject<{
|
|
5
|
+
id: z.ZodString;
|
|
6
|
+
group: z.ZodString;
|
|
7
|
+
text: z.ZodString;
|
|
8
|
+
source: z.ZodEnum<{
|
|
9
|
+
credential_file: "credential_file";
|
|
10
|
+
tool_output: "tool_output";
|
|
11
|
+
user_message: "user_message";
|
|
12
|
+
}>;
|
|
13
|
+
expected: z.ZodArray<z.ZodObject<{
|
|
14
|
+
start: z.ZodNumber;
|
|
15
|
+
end: z.ZodNumber;
|
|
16
|
+
}, z.core.$strip>>;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
export type Case = z.infer<typeof Case>;
|
|
19
|
+
export const Dataset: z.ZodObject<{
|
|
20
|
+
version: z.ZodString;
|
|
21
|
+
cases: z.ZodArray<z.ZodObject<{
|
|
22
|
+
id: z.ZodString;
|
|
23
|
+
group: z.ZodString;
|
|
24
|
+
text: z.ZodString;
|
|
25
|
+
source: z.ZodEnum<{
|
|
26
|
+
credential_file: "credential_file";
|
|
27
|
+
tool_output: "tool_output";
|
|
28
|
+
user_message: "user_message";
|
|
29
|
+
}>;
|
|
30
|
+
expected: z.ZodArray<z.ZodObject<{
|
|
31
|
+
start: z.ZodNumber;
|
|
32
|
+
end: z.ZodNumber;
|
|
33
|
+
}, z.core.$strip>>;
|
|
34
|
+
}, z.core.$strip>>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
export type Dataset = z.infer<typeof Dataset>;
|
|
37
|
+
interface Counts {
|
|
38
|
+
expected: number;
|
|
39
|
+
predicted: number;
|
|
40
|
+
exactMatches: number;
|
|
41
|
+
fullyCovered: number;
|
|
42
|
+
falsePositives: number;
|
|
43
|
+
cleanCases: number;
|
|
44
|
+
completedCleanCases: number;
|
|
45
|
+
falsePositiveCases: number;
|
|
46
|
+
failures: number;
|
|
47
|
+
incomplete: number;
|
|
48
|
+
}
|
|
49
|
+
export function evaluate(detector: SecretDetection.Detector, cases: Case[], options?: {
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}): Promise<{
|
|
52
|
+
detector: {
|
|
53
|
+
id: string;
|
|
54
|
+
version: string;
|
|
55
|
+
};
|
|
56
|
+
metrics: {
|
|
57
|
+
expected: number;
|
|
58
|
+
predicted: number;
|
|
59
|
+
exactMatches: number;
|
|
60
|
+
fullyCovered: number;
|
|
61
|
+
falsePositives: number;
|
|
62
|
+
cleanCases: number;
|
|
63
|
+
completedCleanCases: number;
|
|
64
|
+
falsePositiveCases: number;
|
|
65
|
+
failures: number;
|
|
66
|
+
incomplete: number;
|
|
67
|
+
exactPrecision: number | null;
|
|
68
|
+
exactRecall: number | null;
|
|
69
|
+
fullCoverageRecall: number | null;
|
|
70
|
+
cleanMessageFalsePositiveRate: number | null;
|
|
71
|
+
};
|
|
72
|
+
groups: {
|
|
73
|
+
[k: string]: {
|
|
74
|
+
expected: number;
|
|
75
|
+
predicted: number;
|
|
76
|
+
exactMatches: number;
|
|
77
|
+
fullyCovered: number;
|
|
78
|
+
falsePositives: number;
|
|
79
|
+
cleanCases: number;
|
|
80
|
+
completedCleanCases: number;
|
|
81
|
+
falsePositiveCases: number;
|
|
82
|
+
failures: number;
|
|
83
|
+
incomplete: number;
|
|
84
|
+
exactPrecision: number | null;
|
|
85
|
+
exactRecall: number | null;
|
|
86
|
+
fullCoverageRecall: number | null;
|
|
87
|
+
cleanMessageFalsePositiveRate: number | null;
|
|
88
|
+
};
|
|
89
|
+
};
|
|
90
|
+
cases: ({
|
|
91
|
+
id: string;
|
|
92
|
+
group: string;
|
|
93
|
+
status: "complete" | "incomplete" | "failed";
|
|
94
|
+
error?: string;
|
|
95
|
+
durationMs: number;
|
|
96
|
+
} & Counts)[];
|
|
97
|
+
}>;
|
|
98
|
+
export function benchmark(detector: SecretDetection.Detector, input: SecretDetection.Input, options: {
|
|
99
|
+
samples: number;
|
|
100
|
+
warmup: number;
|
|
101
|
+
timeoutMs?: number;
|
|
102
|
+
}): Promise<{
|
|
103
|
+
samples: number;
|
|
104
|
+
warmup: number;
|
|
105
|
+
firstCallMs: number;
|
|
106
|
+
p50Ms: number;
|
|
107
|
+
p95Ms: number;
|
|
108
|
+
durationMs: number;
|
|
109
|
+
callsPerSecond: number | null;
|
|
110
|
+
}>;
|
|
111
|
+
export {};
|
|
112
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SecretDetection } from "./detector.js";
|
|
3
|
+
export var SecretEvaluation;
|
|
4
|
+
(function (SecretEvaluation) {
|
|
5
|
+
const Span = SecretDetection.Finding.pick({ start: true, end: true });
|
|
6
|
+
SecretEvaluation.Case = z.object({
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
group: z.string().min(1),
|
|
9
|
+
text: z.string(),
|
|
10
|
+
source: SecretDetection.Source,
|
|
11
|
+
expected: z.array(Span),
|
|
12
|
+
});
|
|
13
|
+
SecretEvaluation.Dataset = z.object({ version: z.string().min(1), cases: z.array(SecretEvaluation.Case).min(1) });
|
|
14
|
+
function counts() {
|
|
15
|
+
return {
|
|
16
|
+
expected: 0,
|
|
17
|
+
predicted: 0,
|
|
18
|
+
exactMatches: 0,
|
|
19
|
+
fullyCovered: 0,
|
|
20
|
+
falsePositives: 0,
|
|
21
|
+
cleanCases: 0,
|
|
22
|
+
completedCleanCases: 0,
|
|
23
|
+
falsePositiveCases: 0,
|
|
24
|
+
failures: 0,
|
|
25
|
+
incomplete: 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function metrics(count) {
|
|
29
|
+
const ratio = (a, b) => (b ? a / b : null);
|
|
30
|
+
return {
|
|
31
|
+
...count,
|
|
32
|
+
exactPrecision: ratio(count.exactMatches, count.predicted),
|
|
33
|
+
exactRecall: ratio(count.exactMatches, count.expected),
|
|
34
|
+
fullCoverageRecall: ratio(count.fullyCovered, count.expected),
|
|
35
|
+
cleanMessageFalsePositiveRate: ratio(count.falsePositiveCases, count.completedCleanCases),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
async function evaluate(detector, cases, options = {}) {
|
|
39
|
+
const total = counts();
|
|
40
|
+
const groups = new Map();
|
|
41
|
+
const rows = [];
|
|
42
|
+
const ids = new Set();
|
|
43
|
+
for (const sample of cases) {
|
|
44
|
+
SecretEvaluation.Case.parse(sample);
|
|
45
|
+
if (ids.has(sample.id))
|
|
46
|
+
throw new Error("Duplicate evaluation case id");
|
|
47
|
+
ids.add(sample.id);
|
|
48
|
+
SecretDetection.validate(sample.text, {
|
|
49
|
+
complete: true,
|
|
50
|
+
findings: sample.expected.map((span) => ({ ...span, kind: "label" })),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
for (const sample of cases) {
|
|
54
|
+
const count = counts();
|
|
55
|
+
count.expected = sample.expected.length;
|
|
56
|
+
count.cleanCases = sample.expected.length === 0 ? 1 : 0;
|
|
57
|
+
const start = performance.now();
|
|
58
|
+
let status = "complete";
|
|
59
|
+
let error;
|
|
60
|
+
try {
|
|
61
|
+
const result = await SecretDetection.detect(detector, sample, options);
|
|
62
|
+
if (!result.complete) {
|
|
63
|
+
status = "incomplete";
|
|
64
|
+
count.incomplete = 1;
|
|
65
|
+
}
|
|
66
|
+
count.predicted = result.findings.length;
|
|
67
|
+
count.exactMatches = sample.expected.filter((label) => result.findings.some((f) => f.start === label.start && f.end === label.end)).length;
|
|
68
|
+
count.fullyCovered = sample.expected.filter((label) => result.findings.some((f) => f.start <= label.start && f.end >= label.end)).length;
|
|
69
|
+
count.falsePositives = result.findings.filter((f) => !sample.expected.some((label) => f.start < label.end && f.end > label.start)).length;
|
|
70
|
+
count.completedCleanCases = result.complete ? count.cleanCases : 0;
|
|
71
|
+
count.falsePositiveCases = count.completedCleanCases && count.predicted > 0 ? 1 : 0;
|
|
72
|
+
}
|
|
73
|
+
catch (failure) {
|
|
74
|
+
status = "failed";
|
|
75
|
+
count.failures = 1;
|
|
76
|
+
error = failure instanceof SecretDetection.Error ? failure.code : "detector_failed";
|
|
77
|
+
}
|
|
78
|
+
const durationMs = performance.now() - start;
|
|
79
|
+
const group = groups.get(sample.group) ?? counts();
|
|
80
|
+
groups.set(sample.group, group);
|
|
81
|
+
for (const key of Object.keys(count)) {
|
|
82
|
+
total[key] += count[key];
|
|
83
|
+
group[key] += count[key];
|
|
84
|
+
}
|
|
85
|
+
rows.push({ id: sample.id, group: sample.group, status, ...(error ? { error } : {}), durationMs, ...count });
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
detector: { id: detector.id, version: detector.version },
|
|
89
|
+
metrics: metrics(total),
|
|
90
|
+
groups: Object.fromEntries([...groups].map(([key, count]) => [key, metrics(count)])),
|
|
91
|
+
cases: rows,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
SecretEvaluation.evaluate = evaluate;
|
|
95
|
+
async function benchmark(detector, input, options) {
|
|
96
|
+
if (!Number.isInteger(options.samples) ||
|
|
97
|
+
options.samples < 1 ||
|
|
98
|
+
!Number.isInteger(options.warmup) ||
|
|
99
|
+
options.warmup < 0)
|
|
100
|
+
throw new RangeError("Invalid benchmark sample count");
|
|
101
|
+
const start = performance.now();
|
|
102
|
+
const first = await SecretDetection.detect(detector, input, options);
|
|
103
|
+
const firstCallMs = performance.now() - start;
|
|
104
|
+
if (!first.complete)
|
|
105
|
+
throw new SecretDetection.Error("incomplete");
|
|
106
|
+
for (let i = 0; i < options.warmup; i++) {
|
|
107
|
+
const result = await SecretDetection.detect(detector, input, options);
|
|
108
|
+
if (!result.complete)
|
|
109
|
+
throw new SecretDetection.Error("incomplete");
|
|
110
|
+
}
|
|
111
|
+
const elapsed = [];
|
|
112
|
+
for (let i = 0; i < options.samples; i++) {
|
|
113
|
+
const begin = performance.now();
|
|
114
|
+
const result = await SecretDetection.detect(detector, input, options);
|
|
115
|
+
elapsed.push(performance.now() - begin);
|
|
116
|
+
if (!result.complete)
|
|
117
|
+
throw new SecretDetection.Error("incomplete");
|
|
118
|
+
}
|
|
119
|
+
elapsed.sort((a, b) => a - b);
|
|
120
|
+
const durationMs = elapsed.reduce((a, b) => a + b, 0);
|
|
121
|
+
return {
|
|
122
|
+
samples: options.samples,
|
|
123
|
+
warmup: options.warmup,
|
|
124
|
+
firstCallMs,
|
|
125
|
+
p50Ms: elapsed[Math.ceil(elapsed.length * 0.5) - 1],
|
|
126
|
+
p95Ms: elapsed[Math.ceil(elapsed.length * 0.95) - 1],
|
|
127
|
+
durationMs,
|
|
128
|
+
callsPerSecond: durationMs > 0 ? (options.samples * 1000) / durationMs : null,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
SecretEvaluation.benchmark = benchmark;
|
|
132
|
+
})(SecretEvaluation || (SecretEvaluation = {}));
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export declare namespace SecretPatterns {
|
|
2
|
+
/**
|
|
3
|
+
* Single inventory of standalone secret token shapes. Consumers
|
|
4
|
+
* (observability redaction, heuristic capture) read from here so a new
|
|
5
|
+
* provider prefix updates one place instead of drifting across copies.
|
|
6
|
+
*/
|
|
7
|
+
const standalone: readonly RegExp[];
|
|
8
|
+
/**
|
|
9
|
+
* Long-form token matcher used by SmartAllow evidence redaction: stricter
|
|
10
|
+
* minimum lengths plus a generic high-entropy catch-all.
|
|
11
|
+
*/
|
|
12
|
+
function longForm(): RegExp;
|
|
13
|
+
const authSchemes: readonly [RegExp, string][];
|
|
14
|
+
const keyValue: RegExp;
|
|
15
|
+
const queryParam: RegExp;
|
|
16
|
+
function oauthCodes(text: string): Array<{
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
}>;
|
|
20
|
+
/** Replace every standalone token match; returns the text and match count. */
|
|
21
|
+
function replaceStandalone(text: string, replacement: string): {
|
|
22
|
+
text: string;
|
|
23
|
+
matches: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/patterns.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export var SecretPatterns;
|
|
2
|
+
(function (SecretPatterns) {
|
|
3
|
+
/**
|
|
4
|
+
* Single inventory of standalone secret token shapes. Consumers
|
|
5
|
+
* (observability redaction, heuristic capture) read from here so a new
|
|
6
|
+
* provider prefix updates one place instead of drifting across copies.
|
|
7
|
+
*/
|
|
8
|
+
SecretPatterns.standalone = [
|
|
9
|
+
/\bsk-[A-Za-z0-9._-]{8,}\b/g,
|
|
10
|
+
/\bghp_[A-Za-z0-9_]{8,}\b/g,
|
|
11
|
+
/\bgithub_pat_[A-Za-z0-9_]{8,}\b/g,
|
|
12
|
+
/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g,
|
|
13
|
+
/\bhf_[A-Za-z0-9_]{8,}\b/g,
|
|
14
|
+
/\bglpat-[A-Za-z0-9-]{8,}\b/g,
|
|
15
|
+
/\bpk_live_[A-Za-z0-9_]{8,}\b/g,
|
|
16
|
+
/\brk_live_[A-Za-z0-9_]{8,}\b/g,
|
|
17
|
+
/\btok_[A-Za-z0-9._-]{8,}\b/g,
|
|
18
|
+
/\bkey_[A-Za-z0-9._-]{8,}\b/g,
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Long-form token matcher used by SmartAllow evidence redaction: stricter
|
|
22
|
+
* minimum lengths plus a generic high-entropy catch-all.
|
|
23
|
+
*/
|
|
24
|
+
function longForm() {
|
|
25
|
+
return /\b(?:sk-[A-Za-z0-9_-]{16,}|sk-proj-[A-Za-z0-9_-]{16,}|github_pat_[A-Za-z0-9_]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|[A-Za-z0-9+/=_-]{48,})\b/g;
|
|
26
|
+
}
|
|
27
|
+
SecretPatterns.longForm = longForm;
|
|
28
|
+
SecretPatterns.authSchemes = [
|
|
29
|
+
[/(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[redacted]"],
|
|
30
|
+
[/(Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[redacted]"],
|
|
31
|
+
[/(Digest\s+)[A-Za-z0-9+/=]+/gi, "$1[redacted]"],
|
|
32
|
+
];
|
|
33
|
+
SecretPatterns.keyValue = /((?:token|secret|password|authorization|api[_-]?key|cookie)["']?\s*[:=]\s*["']?)[^\s"'&]+/gi;
|
|
34
|
+
SecretPatterns.queryParam = /([?&](?:token|secret|password|authorization|api[_-]?key|cookie|key)=)[^&#\s"']+/gi;
|
|
35
|
+
function oauthCodes(text) {
|
|
36
|
+
const spans = [];
|
|
37
|
+
for (const url of text.matchAll(/\bhttps?:\/\/[^\s"'<>\\()[\]{}]+/gi)) {
|
|
38
|
+
let pathname;
|
|
39
|
+
try {
|
|
40
|
+
pathname = new URL(url[0]).pathname;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!/\/(?:auth|oauth2?|authorize|callback)(?:\/|$)/i.test(pathname))
|
|
46
|
+
continue;
|
|
47
|
+
for (const match of url[0].matchAll(/([?&]code=)([^&#]+)/gi)) {
|
|
48
|
+
if (match[2].startsWith("⟦sec:"))
|
|
49
|
+
continue;
|
|
50
|
+
const start = url.index + match.index + match[1].length;
|
|
51
|
+
spans.push({ start, end: start + match[2].length });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return spans;
|
|
55
|
+
}
|
|
56
|
+
SecretPatterns.oauthCodes = oauthCodes;
|
|
57
|
+
/** Replace every standalone token match; returns the text and match count. */
|
|
58
|
+
function replaceStandalone(text, replacement) {
|
|
59
|
+
let matches = 0;
|
|
60
|
+
let result = text;
|
|
61
|
+
for (const pattern of SecretPatterns.standalone) {
|
|
62
|
+
result = result.replace(pattern, () => {
|
|
63
|
+
matches++;
|
|
64
|
+
return replacement;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { text: result, matches };
|
|
68
|
+
}
|
|
69
|
+
SecretPatterns.replaceStandalone = replaceStandalone;
|
|
70
|
+
})(SecretPatterns || (SecretPatterns = {}));
|
package/dist/regex.d.ts
ADDED
package/dist/regex.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { SecretPatterns } from "./patterns.js";
|
|
2
|
+
export const RegexDetector = {
|
|
3
|
+
id: "standalone-regex",
|
|
4
|
+
version: "2",
|
|
5
|
+
async detect({ text, signal }) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
const found = SecretPatterns.oauthCodes(text).map((span) => ({
|
|
8
|
+
...span,
|
|
9
|
+
kind: "authorization_code",
|
|
10
|
+
}));
|
|
11
|
+
for (const pattern of SecretPatterns.standalone) {
|
|
12
|
+
for (const match of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
|
|
13
|
+
found.push({ start: match.index, end: match.index + match[0].length, kind: "token" });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
found.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
17
|
+
const findings = [];
|
|
18
|
+
for (const finding of found) {
|
|
19
|
+
const previous = findings.at(-1);
|
|
20
|
+
if (previous && finding.start < previous.end)
|
|
21
|
+
previous.end = Math.max(previous.end, finding.end);
|
|
22
|
+
else
|
|
23
|
+
findings.push(finding);
|
|
24
|
+
}
|
|
25
|
+
return { findings, complete: true };
|
|
26
|
+
},
|
|
27
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ericsanchezok/synergy-secret-detection",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "3.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./patterns": {
|
|
12
|
+
"types": "./dist/patterns.d.ts",
|
|
13
|
+
"import": "./dist/patterns.js"
|
|
14
|
+
},
|
|
15
|
+
"./evaluation": {
|
|
16
|
+
"types": "./dist/evaluation.d.ts",
|
|
17
|
+
"import": "./dist/evaluation.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"build": "bun ../../script/build-workspace.ts packages/secret-detection && tsc -p tsconfig.build.json",
|
|
23
|
+
"test": "bun test test/",
|
|
24
|
+
"evaluate": "bun script/evaluate.ts"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"src",
|
|
29
|
+
"README.md"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"zod": "4.6.7"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/detector.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
|
|
3
|
+
export namespace SecretDetection {
|
|
4
|
+
export const Source = z.enum(["user_message", "tool_output", "credential_file"])
|
|
5
|
+
export type Source = z.infer<typeof Source>
|
|
6
|
+
export const Finding = z.object({
|
|
7
|
+
start: z.number().int().nonnegative(),
|
|
8
|
+
end: z.number().int().positive(),
|
|
9
|
+
kind: z.string().min(1),
|
|
10
|
+
score: z.number().min(0).max(1).optional(),
|
|
11
|
+
})
|
|
12
|
+
export type Finding = z.infer<typeof Finding>
|
|
13
|
+
export const Result = z.discriminatedUnion("complete", [
|
|
14
|
+
z.object({ complete: z.literal(true), findings: z.array(Finding) }),
|
|
15
|
+
z.object({
|
|
16
|
+
complete: z.literal(false),
|
|
17
|
+
findings: z.array(Finding),
|
|
18
|
+
reason: z.enum(["input_limit", "unsupported"]),
|
|
19
|
+
}),
|
|
20
|
+
])
|
|
21
|
+
export type Result = z.infer<typeof Result>
|
|
22
|
+
export interface Input {
|
|
23
|
+
text: string
|
|
24
|
+
source: Source
|
|
25
|
+
signal?: AbortSignal
|
|
26
|
+
}
|
|
27
|
+
export interface Detector {
|
|
28
|
+
readonly id: string
|
|
29
|
+
readonly version: string
|
|
30
|
+
detect(input: Input): Promise<Result>
|
|
31
|
+
}
|
|
32
|
+
export class Error extends globalThis.Error {
|
|
33
|
+
constructor(readonly code: "aborted" | "timeout" | "invalid_result" | "detector_failed" | "incomplete") {
|
|
34
|
+
super(`Secret detection failed: ${code}`)
|
|
35
|
+
this.name = "SecretDetectionError"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function splitsSurrogate(text: string, offset: number) {
|
|
40
|
+
const left = text.charCodeAt(offset - 1)
|
|
41
|
+
const right = text.charCodeAt(offset)
|
|
42
|
+
return left >= 0xd800 && left <= 0xdbff && right >= 0xdc00 && right <= 0xdfff
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function validate(text: string, result: unknown): Result {
|
|
46
|
+
const parsed = Result.safeParse(result)
|
|
47
|
+
if (!parsed.success) throw new Error("invalid_result")
|
|
48
|
+
let end = 0
|
|
49
|
+
for (const finding of parsed.data.findings) {
|
|
50
|
+
if (
|
|
51
|
+
finding.start < end ||
|
|
52
|
+
finding.end <= finding.start ||
|
|
53
|
+
finding.end > text.length ||
|
|
54
|
+
splitsSurrogate(text, finding.start) ||
|
|
55
|
+
splitsSurrogate(text, finding.end)
|
|
56
|
+
)
|
|
57
|
+
throw new Error("invalid_result")
|
|
58
|
+
end = finding.end
|
|
59
|
+
}
|
|
60
|
+
return parsed.data
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function detect(
|
|
64
|
+
detector: Detector,
|
|
65
|
+
input: Input,
|
|
66
|
+
options: { timeoutMs?: number } = {},
|
|
67
|
+
): Promise<Result> {
|
|
68
|
+
if (input.signal?.aborted) throw new Error("aborted")
|
|
69
|
+
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
|
|
70
|
+
throw new RangeError("timeoutMs must be a positive finite number")
|
|
71
|
+
}
|
|
72
|
+
const controller = new AbortController()
|
|
73
|
+
let rejectAbort!: (error: Error) => void
|
|
74
|
+
const cancelled = new Promise<never>((_, reject) => {
|
|
75
|
+
rejectAbort = reject
|
|
76
|
+
})
|
|
77
|
+
const cancel = (code: "aborted" | "timeout") => {
|
|
78
|
+
rejectAbort(new Error(code))
|
|
79
|
+
controller.abort()
|
|
80
|
+
}
|
|
81
|
+
const onAbort = () => cancel("aborted")
|
|
82
|
+
input.signal?.addEventListener("abort", onAbort, { once: true })
|
|
83
|
+
const timer = options.timeoutMs === undefined ? undefined : setTimeout(() => cancel("timeout"), options.timeoutMs)
|
|
84
|
+
try {
|
|
85
|
+
const result = await Promise.race([
|
|
86
|
+
Promise.resolve().then(() =>
|
|
87
|
+
detector.detect({ text: input.text, source: input.source, signal: controller.signal }),
|
|
88
|
+
),
|
|
89
|
+
cancelled,
|
|
90
|
+
])
|
|
91
|
+
if (input.signal?.aborted) throw new Error("aborted")
|
|
92
|
+
return validate(input.text, result)
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error instanceof Error) throw error
|
|
95
|
+
throw new Error("detector_failed")
|
|
96
|
+
} finally {
|
|
97
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
98
|
+
input.signal?.removeEventListener("abort", onAbort)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { z } from "zod"
|
|
2
|
+
import { SecretDetection } from "./detector.js"
|
|
3
|
+
|
|
4
|
+
export namespace SecretEvaluation {
|
|
5
|
+
const Span = SecretDetection.Finding.pick({ start: true, end: true })
|
|
6
|
+
export const Case = z.object({
|
|
7
|
+
id: z.string().min(1),
|
|
8
|
+
group: z.string().min(1),
|
|
9
|
+
text: z.string(),
|
|
10
|
+
source: SecretDetection.Source,
|
|
11
|
+
expected: z.array(Span),
|
|
12
|
+
})
|
|
13
|
+
export type Case = z.infer<typeof Case>
|
|
14
|
+
export const Dataset = z.object({ version: z.string().min(1), cases: z.array(Case).min(1) })
|
|
15
|
+
export type Dataset = z.infer<typeof Dataset>
|
|
16
|
+
|
|
17
|
+
interface Counts {
|
|
18
|
+
expected: number
|
|
19
|
+
predicted: number
|
|
20
|
+
exactMatches: number
|
|
21
|
+
fullyCovered: number
|
|
22
|
+
falsePositives: number
|
|
23
|
+
cleanCases: number
|
|
24
|
+
completedCleanCases: number
|
|
25
|
+
falsePositiveCases: number
|
|
26
|
+
failures: number
|
|
27
|
+
incomplete: number
|
|
28
|
+
}
|
|
29
|
+
function counts(): Counts {
|
|
30
|
+
return {
|
|
31
|
+
expected: 0,
|
|
32
|
+
predicted: 0,
|
|
33
|
+
exactMatches: 0,
|
|
34
|
+
fullyCovered: 0,
|
|
35
|
+
falsePositives: 0,
|
|
36
|
+
cleanCases: 0,
|
|
37
|
+
completedCleanCases: 0,
|
|
38
|
+
falsePositiveCases: 0,
|
|
39
|
+
failures: 0,
|
|
40
|
+
incomplete: 0,
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function metrics(count: Counts) {
|
|
44
|
+
const ratio = (a: number, b: number) => (b ? a / b : null)
|
|
45
|
+
return {
|
|
46
|
+
...count,
|
|
47
|
+
exactPrecision: ratio(count.exactMatches, count.predicted),
|
|
48
|
+
exactRecall: ratio(count.exactMatches, count.expected),
|
|
49
|
+
fullCoverageRecall: ratio(count.fullyCovered, count.expected),
|
|
50
|
+
cleanMessageFalsePositiveRate: ratio(count.falsePositiveCases, count.completedCleanCases),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function evaluate(
|
|
55
|
+
detector: SecretDetection.Detector,
|
|
56
|
+
cases: Case[],
|
|
57
|
+
options: { timeoutMs?: number } = {},
|
|
58
|
+
) {
|
|
59
|
+
const total = counts()
|
|
60
|
+
const groups = new Map<string, Counts>()
|
|
61
|
+
const rows: Array<
|
|
62
|
+
{
|
|
63
|
+
id: string
|
|
64
|
+
group: string
|
|
65
|
+
status: "complete" | "incomplete" | "failed"
|
|
66
|
+
error?: string
|
|
67
|
+
durationMs: number
|
|
68
|
+
} & Counts
|
|
69
|
+
> = []
|
|
70
|
+
const ids = new Set<string>()
|
|
71
|
+
for (const sample of cases) {
|
|
72
|
+
Case.parse(sample)
|
|
73
|
+
if (ids.has(sample.id)) throw new Error("Duplicate evaluation case id")
|
|
74
|
+
ids.add(sample.id)
|
|
75
|
+
SecretDetection.validate(sample.text, {
|
|
76
|
+
complete: true,
|
|
77
|
+
findings: sample.expected.map((span) => ({ ...span, kind: "label" })),
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
for (const sample of cases) {
|
|
81
|
+
const count = counts()
|
|
82
|
+
count.expected = sample.expected.length
|
|
83
|
+
count.cleanCases = sample.expected.length === 0 ? 1 : 0
|
|
84
|
+
const start = performance.now()
|
|
85
|
+
let status: "complete" | "incomplete" | "failed" = "complete"
|
|
86
|
+
let error: string | undefined
|
|
87
|
+
try {
|
|
88
|
+
const result = await SecretDetection.detect(detector, sample, options)
|
|
89
|
+
if (!result.complete) {
|
|
90
|
+
status = "incomplete"
|
|
91
|
+
count.incomplete = 1
|
|
92
|
+
}
|
|
93
|
+
count.predicted = result.findings.length
|
|
94
|
+
count.exactMatches = sample.expected.filter((label) =>
|
|
95
|
+
result.findings.some((f) => f.start === label.start && f.end === label.end),
|
|
96
|
+
).length
|
|
97
|
+
count.fullyCovered = sample.expected.filter((label) =>
|
|
98
|
+
result.findings.some((f) => f.start <= label.start && f.end >= label.end),
|
|
99
|
+
).length
|
|
100
|
+
count.falsePositives = result.findings.filter(
|
|
101
|
+
(f) => !sample.expected.some((label) => f.start < label.end && f.end > label.start),
|
|
102
|
+
).length
|
|
103
|
+
count.completedCleanCases = result.complete ? count.cleanCases : 0
|
|
104
|
+
count.falsePositiveCases = count.completedCleanCases && count.predicted > 0 ? 1 : 0
|
|
105
|
+
} catch (failure) {
|
|
106
|
+
status = "failed"
|
|
107
|
+
count.failures = 1
|
|
108
|
+
error = failure instanceof SecretDetection.Error ? failure.code : "detector_failed"
|
|
109
|
+
}
|
|
110
|
+
const durationMs = performance.now() - start
|
|
111
|
+
const group = groups.get(sample.group) ?? counts()
|
|
112
|
+
groups.set(sample.group, group)
|
|
113
|
+
for (const key of Object.keys(count) as Array<keyof Counts>) {
|
|
114
|
+
total[key] += count[key]
|
|
115
|
+
group[key] += count[key]
|
|
116
|
+
}
|
|
117
|
+
rows.push({ id: sample.id, group: sample.group, status, ...(error ? { error } : {}), durationMs, ...count })
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
detector: { id: detector.id, version: detector.version },
|
|
121
|
+
metrics: metrics(total),
|
|
122
|
+
groups: Object.fromEntries([...groups].map(([key, count]) => [key, metrics(count)])),
|
|
123
|
+
cases: rows,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function benchmark(
|
|
128
|
+
detector: SecretDetection.Detector,
|
|
129
|
+
input: SecretDetection.Input,
|
|
130
|
+
options: { samples: number; warmup: number; timeoutMs?: number },
|
|
131
|
+
) {
|
|
132
|
+
if (
|
|
133
|
+
!Number.isInteger(options.samples) ||
|
|
134
|
+
options.samples < 1 ||
|
|
135
|
+
!Number.isInteger(options.warmup) ||
|
|
136
|
+
options.warmup < 0
|
|
137
|
+
)
|
|
138
|
+
throw new RangeError("Invalid benchmark sample count")
|
|
139
|
+
const start = performance.now()
|
|
140
|
+
const first = await SecretDetection.detect(detector, input, options)
|
|
141
|
+
const firstCallMs = performance.now() - start
|
|
142
|
+
if (!first.complete) throw new SecretDetection.Error("incomplete")
|
|
143
|
+
for (let i = 0; i < options.warmup; i++) {
|
|
144
|
+
const result = await SecretDetection.detect(detector, input, options)
|
|
145
|
+
if (!result.complete) throw new SecretDetection.Error("incomplete")
|
|
146
|
+
}
|
|
147
|
+
const elapsed: number[] = []
|
|
148
|
+
for (let i = 0; i < options.samples; i++) {
|
|
149
|
+
const begin = performance.now()
|
|
150
|
+
const result = await SecretDetection.detect(detector, input, options)
|
|
151
|
+
elapsed.push(performance.now() - begin)
|
|
152
|
+
if (!result.complete) throw new SecretDetection.Error("incomplete")
|
|
153
|
+
}
|
|
154
|
+
elapsed.sort((a, b) => a - b)
|
|
155
|
+
const durationMs = elapsed.reduce((a, b) => a + b, 0)
|
|
156
|
+
return {
|
|
157
|
+
samples: options.samples,
|
|
158
|
+
warmup: options.warmup,
|
|
159
|
+
firstCallMs,
|
|
160
|
+
p50Ms: elapsed[Math.ceil(elapsed.length * 0.5) - 1],
|
|
161
|
+
p95Ms: elapsed[Math.ceil(elapsed.length * 0.95) - 1],
|
|
162
|
+
durationMs,
|
|
163
|
+
callsPerSecond: durationMs > 0 ? (options.samples * 1000) / durationMs : null,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/index.ts
ADDED
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export namespace SecretPatterns {
|
|
2
|
+
/**
|
|
3
|
+
* Single inventory of standalone secret token shapes. Consumers
|
|
4
|
+
* (observability redaction, heuristic capture) read from here so a new
|
|
5
|
+
* provider prefix updates one place instead of drifting across copies.
|
|
6
|
+
*/
|
|
7
|
+
export const standalone: readonly RegExp[] = [
|
|
8
|
+
/\bsk-[A-Za-z0-9._-]{8,}\b/g,
|
|
9
|
+
/\bghp_[A-Za-z0-9_]{8,}\b/g,
|
|
10
|
+
/\bgithub_pat_[A-Za-z0-9_]{8,}\b/g,
|
|
11
|
+
/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g,
|
|
12
|
+
/\bhf_[A-Za-z0-9_]{8,}\b/g,
|
|
13
|
+
/\bglpat-[A-Za-z0-9-]{8,}\b/g,
|
|
14
|
+
/\bpk_live_[A-Za-z0-9_]{8,}\b/g,
|
|
15
|
+
/\brk_live_[A-Za-z0-9_]{8,}\b/g,
|
|
16
|
+
/\btok_[A-Za-z0-9._-]{8,}\b/g,
|
|
17
|
+
/\bkey_[A-Za-z0-9._-]{8,}\b/g,
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Long-form token matcher used by SmartAllow evidence redaction: stricter
|
|
22
|
+
* minimum lengths plus a generic high-entropy catch-all.
|
|
23
|
+
*/
|
|
24
|
+
export function longForm(): RegExp {
|
|
25
|
+
return /\b(?:sk-[A-Za-z0-9_-]{16,}|sk-proj-[A-Za-z0-9_-]{16,}|github_pat_[A-Za-z0-9_]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|[A-Za-z0-9+/=_-]{48,})\b/g
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const authSchemes: readonly [RegExp, string][] = [
|
|
29
|
+
[/(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[redacted]"],
|
|
30
|
+
[/(Basic\s+)[A-Za-z0-9+/=]+/gi, "$1[redacted]"],
|
|
31
|
+
[/(Digest\s+)[A-Za-z0-9+/=]+/gi, "$1[redacted]"],
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
export const keyValue = /((?:token|secret|password|authorization|api[_-]?key|cookie)["']?\s*[:=]\s*["']?)[^\s"'&]+/gi
|
|
35
|
+
|
|
36
|
+
export const queryParam = /([?&](?:token|secret|password|authorization|api[_-]?key|cookie|key)=)[^&#\s"']+/gi
|
|
37
|
+
|
|
38
|
+
export function oauthCodes(text: string): Array<{ start: number; end: number }> {
|
|
39
|
+
const spans: Array<{ start: number; end: number }> = []
|
|
40
|
+
for (const url of text.matchAll(/\bhttps?:\/\/[^\s"'<>\\()[\]{}]+/gi)) {
|
|
41
|
+
let pathname: string
|
|
42
|
+
try {
|
|
43
|
+
pathname = new URL(url[0]).pathname
|
|
44
|
+
} catch {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
if (!/\/(?:auth|oauth2?|authorize|callback)(?:\/|$)/i.test(pathname)) continue
|
|
48
|
+
for (const match of url[0].matchAll(/([?&]code=)([^&#]+)/gi)) {
|
|
49
|
+
if (match[2]!.startsWith("⟦sec:")) continue
|
|
50
|
+
const start = url.index + match.index + match[1]!.length
|
|
51
|
+
spans.push({ start, end: start + match[2]!.length })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return spans
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Replace every standalone token match; returns the text and match count. */
|
|
58
|
+
export function replaceStandalone(text: string, replacement: string): { text: string; matches: number } {
|
|
59
|
+
let matches = 0
|
|
60
|
+
let result = text
|
|
61
|
+
for (const pattern of standalone) {
|
|
62
|
+
result = result.replace(pattern, () => {
|
|
63
|
+
matches++
|
|
64
|
+
return replacement
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return { text: result, matches }
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/regex.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { SecretDetection } from "./detector.js"
|
|
2
|
+
import { SecretPatterns } from "./patterns.js"
|
|
3
|
+
|
|
4
|
+
export const RegexDetector: SecretDetection.Detector = {
|
|
5
|
+
id: "standalone-regex",
|
|
6
|
+
version: "2",
|
|
7
|
+
async detect({ text, signal }) {
|
|
8
|
+
signal?.throwIfAborted()
|
|
9
|
+
const found: SecretDetection.Finding[] = SecretPatterns.oauthCodes(text).map((span) => ({
|
|
10
|
+
...span,
|
|
11
|
+
kind: "authorization_code",
|
|
12
|
+
}))
|
|
13
|
+
for (const pattern of SecretPatterns.standalone) {
|
|
14
|
+
for (const match of text.matchAll(new RegExp(pattern.source, pattern.flags))) {
|
|
15
|
+
found.push({ start: match.index, end: match.index + match[0].length, kind: "token" })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
found.sort((a, b) => a.start - b.start || b.end - a.end)
|
|
19
|
+
const findings: SecretDetection.Finding[] = []
|
|
20
|
+
for (const finding of found) {
|
|
21
|
+
const previous = findings.at(-1)
|
|
22
|
+
if (previous && finding.start < previous.end) previous.end = Math.max(previous.end, finding.end)
|
|
23
|
+
else findings.push(finding)
|
|
24
|
+
}
|
|
25
|
+
return { findings, complete: true }
|
|
26
|
+
},
|
|
27
|
+
}
|