@erclx/aitk 2.1.0 → 2.2.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.
@@ -244,7 +244,11 @@ function countLine(counts: Record<string, number>): string {
244
244
  * against an absent baseline says the same as a corpus that did not move.
245
245
  */
246
246
  function deltaLine(delta: Delta): string | undefined {
247
- if (delta.kind === 'per-machine' || delta.kind === 'unmeasured') {
247
+ if (
248
+ delta.kind === 'per-machine' ||
249
+ delta.kind === 'upstream' ||
250
+ delta.kind === 'unmeasured'
251
+ ) {
248
252
  return undefined
249
253
  }
250
254
  if (delta.kind === 'unrecorded') return 'No recorded baseline to compare'
@@ -301,7 +305,11 @@ function report(
301
305
  }
302
306
 
303
307
  if (!result.tracked) {
304
- logInfo('Per-machine corpus, so no baseline is kept')
308
+ logInfo(
309
+ result.corpus === 'upstream'
310
+ ? 'Upstream index, so no baseline is kept and growth is not this tree'
311
+ : 'Per-machine corpus, so no baseline is kept',
312
+ )
305
313
  continue
306
314
  }
307
315
 
@@ -335,7 +343,7 @@ function report(
335
343
  // Stated on every run, including a clean one. A count of what passed reads as
336
344
  // a verdict on the whole set unless the run also says what it never reached.
337
345
  logInfo(
338
- `${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent on this machine`,
346
+ `${summary.audited} of ${results.length} corpora measured, ${summary.absent} absent or unreachable from this machine`,
339
347
  )
340
348
 
341
349
  outro()
@@ -0,0 +1,173 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import {
4
+ type Advisory,
5
+ auditDependencies,
6
+ type AuditRefusal,
7
+ countBySeverity,
8
+ SEVERITIES,
9
+ } from '@/deps/audit'
10
+ import {
11
+ intro,
12
+ logInfo,
13
+ logStep,
14
+ logWarn,
15
+ outro,
16
+ pipeOutput,
17
+ plural,
18
+ } from '@/ui'
19
+
20
+ interface AuditCommandOptions {
21
+ readonly json?: boolean
22
+ }
23
+
24
+ /** What a reader does about each way the advisory list fails to arrive. */
25
+ const REFUSALS: Record<AuditRefusal, string> = {
26
+ 'no-manifest': 'No package.json here, so there is no dependency set to read.',
27
+ 'no-lockfile':
28
+ 'No lockfile beside the manifest, so no dependency set is resolved yet. Install first.',
29
+ 'no-record':
30
+ 'The advisory lookup returned no record. Check the network, then re-run.',
31
+ }
32
+
33
+ export function register(program: Command): void {
34
+ const deps = program
35
+ .command('deps')
36
+ .description('Read the installed dependency set for published advisories')
37
+ .helpOption('-h, --help', 'Show this help message')
38
+
39
+ deps
40
+ .command('audit')
41
+ .description('Report advisories against the dependencies already resolved')
42
+ .argument('[path]', 'Project to audit, defaulting to the current directory')
43
+ .helpOption('-h, --help', 'Show this help message')
44
+ .option('--json', 'Add a machine-readable record on stdout')
45
+ .addHelpText(
46
+ 'after',
47
+ [
48
+ '',
49
+ 'Scope:',
50
+ " The resolved dependency set, read through the runtime's own",
51
+ ' advisory command. This reaches a network, so a lookup that fails',
52
+ ' refuses rather than reporting a clean tree.',
53
+ '',
54
+ 'Exit codes:',
55
+ ' 0 no advisory against the resolved set',
56
+ ' 1 refused, with the reason on stderr',
57
+ ' 2 at least one advisory was published',
58
+ '',
59
+ 'Examples:',
60
+ ' aitk deps audit',
61
+ ' aitk deps audit --json',
62
+ '',
63
+ ].join('\n'),
64
+ )
65
+ .action(async (path: string | undefined, opts: AuditCommandOptions) => {
66
+ process.exitCode = await runAudit(path, opts)
67
+ })
68
+ }
69
+
70
+ function severityLine(counts: Record<string, number>): string {
71
+ return SEVERITIES.filter((severity) => counts[severity] !== 0)
72
+ .map((severity) => `${counts[severity]} ${severity}`)
73
+ .join(', ')
74
+ }
75
+
76
+ /**
77
+ * Groups by package, since one dependency commonly carries several advisories
78
+ * and a flat list reads as more distinct upgrades than the tree actually owes.
79
+ */
80
+ function byPackage(advisories: readonly Advisory[]): Map<string, Advisory[]> {
81
+ const grouped = new Map<string, Advisory[]>()
82
+
83
+ for (const advisory of advisories) {
84
+ const held = grouped.get(advisory.package) ?? []
85
+ held.push(advisory)
86
+ grouped.set(advisory.package, held)
87
+ }
88
+
89
+ return grouped
90
+ }
91
+
92
+ async function runAudit(
93
+ path: string | undefined,
94
+ opts: AuditCommandOptions,
95
+ ): Promise<number> {
96
+ const root = resolve(path ?? process.cwd())
97
+ const emitJson = opts.json ?? false
98
+
99
+ intro('aitk deps audit')
100
+
101
+ const audit = await auditDependencies(root)
102
+
103
+ if (audit.kind === 'refused') {
104
+ logStep('Refused')
105
+ logWarn(REFUSALS[audit.reason])
106
+ if (audit.message !== undefined) logWarn(audit.message)
107
+ outro()
108
+
109
+ if (emitJson) {
110
+ process.stdout.write(
111
+ `${JSON.stringify({
112
+ root,
113
+ reason: audit.reason,
114
+ message: audit.message ?? REFUSALS[audit.reason],
115
+ })}\n`,
116
+ )
117
+ }
118
+ return 1
119
+ }
120
+
121
+ const counts = countBySeverity(audit.advisories)
122
+
123
+ logStep('Advisories')
124
+ if (audit.advisories.length === 0) {
125
+ logInfo('No advisory against the resolved dependency set.')
126
+ } else {
127
+ const grouped = byPackage(audit.advisories)
128
+ const total = audit.advisories.length
129
+
130
+ logWarn(
131
+ `${total} ${total === 1 ? 'advisory' : 'advisories'} across ${plural(grouped.size, 'package')}: ${severityLine(counts)}`,
132
+ )
133
+
134
+ // Piped rather than logged line by line, since every one of these is a
135
+ // finding and the timeline's own tick would mark each as something that
136
+ // passed. The frame stays, and the list inside it stays unmarked.
137
+ pipeOutput(
138
+ [...grouped]
139
+ .map(([name, held]) =>
140
+ [
141
+ `${name}: ${severityLine(countBySeverity(held))}`,
142
+ ...held.flatMap((advisory) => [
143
+ ` ${advisory.severity.padEnd(8)} ${advisory.title}`,
144
+ ` ${' '.repeat(8)} ${advisory.url}`,
145
+ ]),
146
+ ].join('\n'),
147
+ )
148
+ .join('\n'),
149
+ )
150
+ }
151
+
152
+ // Stated whichever way the count went. An advisory arrives when someone
153
+ // publishes one, so this number moves with no edit here, and a report that
154
+ // does not date itself reads as a fact about the tree rather than about a day.
155
+ logStep('Reading')
156
+ logInfo(
157
+ 'Measured against the advisory index at run time, not at commit time.',
158
+ )
159
+
160
+ outro()
161
+
162
+ if (emitJson) {
163
+ process.stdout.write(
164
+ `${JSON.stringify({
165
+ root,
166
+ severities: counts,
167
+ advisories: audit.advisories,
168
+ })}\n`,
169
+ )
170
+ }
171
+
172
+ return audit.advisories.length === 0 ? 0 : 2
173
+ }
@@ -0,0 +1,132 @@
1
+ import { resolve } from 'node:path'
2
+ import type { Command } from 'commander'
3
+ import { type ScanRefusal, scanShippedTree } from '@/secrets/scan'
4
+ import { intro, logError, logInfo, logStep, logWarn, outro, plural } from '@/ui'
5
+
6
+ interface ScanCommandOptions {
7
+ readonly json?: boolean
8
+ }
9
+
10
+ /** What a reader does about each way the corpus fails to build. */
11
+ const REFUSALS: Record<ScanRefusal, string> = {
12
+ 'no-manifest':
13
+ 'No package.json here, so nothing is published from this tree.',
14
+ 'no-publish':
15
+ 'The manifest declares private, so this project publishes nothing.',
16
+ // Stated as an unread corpus rather than an absent one. A publish with no
17
+ // files field packs the whole tree, so this is the package that ships the
18
+ // most, and calling it nothing to read is the denial the reasoning in
19
+ // src/secrets/shipped.ts warns against.
20
+ 'no-files-field':
21
+ 'package.json declares no files field, so a publish would pack the whole tree. This check reads a declared corpus and left that one unread.',
22
+ 'no-git': 'git could not list this tree, so the corpus is unknown.',
23
+ 'no-shipped-files':
24
+ 'The files field matched nothing git lists, so nothing would be scanned.',
25
+ }
26
+
27
+ export function register(program: Command): void {
28
+ const secrets = program
29
+ .command('secrets')
30
+ .description('Read committed state for credentials that ship to a target')
31
+ .helpOption('-h, --help', 'Show this help message')
32
+
33
+ secrets
34
+ .command('scan')
35
+ .description('Report credential-shaped values in the tree this repo ships')
36
+ .argument(
37
+ '[path]',
38
+ 'Repository to scan, defaulting to the current directory',
39
+ )
40
+ .helpOption('-h, --help', 'Show this help message')
41
+ .option('--json', 'Add a machine-readable record on stdout')
42
+ .addHelpText(
43
+ 'after',
44
+ [
45
+ '',
46
+ 'Scope:',
47
+ " The package's own files field, so the corpus is what npm packs",
48
+ ' and what the plugin ships. Nothing outside it is read.',
49
+ '',
50
+ 'Exit codes:',
51
+ ' 0 the shipped tree carries no credential-shaped value',
52
+ ' 1 refused, with the reason on stderr',
53
+ ' 2 at least one value was found',
54
+ '',
55
+ 'Examples:',
56
+ ' aitk secrets scan',
57
+ ' aitk secrets scan --json',
58
+ '',
59
+ ].join('\n'),
60
+ )
61
+ .action(async (path: string | undefined, opts: ScanCommandOptions) => {
62
+ process.exitCode = await runScan(path, opts)
63
+ })
64
+ }
65
+
66
+ async function runScan(
67
+ path: string | undefined,
68
+ opts: ScanCommandOptions,
69
+ ): Promise<number> {
70
+ const root = resolve(path ?? process.cwd())
71
+ const emitJson = opts.json ?? false
72
+
73
+ intro('aitk secrets scan')
74
+
75
+ const scan = await scanShippedTree(root)
76
+
77
+ if (scan.kind === 'refused') {
78
+ logStep('Refused')
79
+ logWarn(REFUSALS[scan.reason])
80
+ outro()
81
+
82
+ if (emitJson) {
83
+ process.stdout.write(
84
+ `${JSON.stringify({
85
+ root,
86
+ reason: scan.reason,
87
+ message: REFUSALS[scan.reason],
88
+ })}\n`,
89
+ )
90
+ }
91
+ return 1
92
+ }
93
+
94
+ logStep('Corpus')
95
+ logInfo(
96
+ `${plural(scan.files, 'file')} read, ${scan.skipped} skipped as binary or unreadable`,
97
+ )
98
+ // Stated on every run, including a clean one. The corpus answers what the
99
+ // package publishes, and a reader who sees only the passing count reads the
100
+ // verdict as covering the repository.
101
+ logInfo(
102
+ `${scan.listed - scan.files - scan.skipped} of ${scan.listed} listed files sit outside the published corpus and were not read`,
103
+ )
104
+
105
+ logStep('Findings')
106
+ if (scan.findings.length === 0) {
107
+ logInfo('No credential-shaped value in the shipped tree.')
108
+ } else {
109
+ logError(plural(scan.findings.length, 'value'))
110
+ for (const finding of scan.findings) {
111
+ logWarn(
112
+ `${finding.file}:${finding.line}:${finding.column} ${finding.label} ${finding.preview}`,
113
+ )
114
+ }
115
+ }
116
+
117
+ outro()
118
+
119
+ if (emitJson) {
120
+ process.stdout.write(
121
+ `${JSON.stringify({
122
+ root,
123
+ files: scan.files,
124
+ skipped: scan.skipped,
125
+ listed: scan.listed,
126
+ findings: scan.findings,
127
+ })}\n`,
128
+ )
129
+ }
130
+
131
+ return scan.findings.length === 0 ? 0 : 2
132
+ }
@@ -0,0 +1,153 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { execa } from 'execa'
4
+
5
+ /**
6
+ * The severities the advisory index publishes, worst first.
7
+ *
8
+ * `info` is this module's own floor rather than one of theirs. An advisory
9
+ * arriving under a severity nothing here recognizes still exists, and dropping
10
+ * it would shrink the count on the day the vocabulary changed.
11
+ */
12
+ export const SEVERITIES = [
13
+ 'critical',
14
+ 'high',
15
+ 'moderate',
16
+ 'low',
17
+ 'info',
18
+ ] as const
19
+
20
+ export type Severity = (typeof SEVERITIES)[number]
21
+
22
+ export interface Advisory {
23
+ readonly package: string
24
+ readonly id: number
25
+ readonly title: string
26
+ readonly url: string
27
+ readonly severity: Severity
28
+ readonly vulnerableVersions?: string
29
+ }
30
+
31
+ /**
32
+ * Why no advisory list was produced, which is never the same as a clean one.
33
+ *
34
+ * `no-lockfile` is split out from `no-record` because the two take different
35
+ * remedies. An unreachable index is retried, and a project whose dependencies
36
+ * were never resolved is installed first, so one message naming the network
37
+ * would send half the readers at the wrong cause.
38
+ */
39
+ export type AuditRefusal = 'no-manifest' | 'no-lockfile' | 'no-record'
40
+
41
+ /** The lockfiles a resolved dependency set leaves behind, in any manager. */
42
+ const LOCKFILES = [
43
+ 'bun.lock',
44
+ 'bun.lockb',
45
+ 'package-lock.json',
46
+ 'yarn.lock',
47
+ 'pnpm-lock.yaml',
48
+ ]
49
+
50
+ export type DepsAudit =
51
+ | { readonly kind: 'audited'; readonly advisories: readonly Advisory[] }
52
+ | {
53
+ readonly kind: 'refused'
54
+ readonly reason: AuditRefusal
55
+ readonly message?: string
56
+ }
57
+
58
+ function severityOf(value: unknown): Severity {
59
+ return SEVERITIES.includes(value as Severity) ? (value as Severity) : 'info'
60
+ }
61
+
62
+ /**
63
+ * Reads the record `bun audit --json` writes to stdout.
64
+ *
65
+ * The shape is an object keyed by package name, each holding that package's
66
+ * advisories, which this flattens into one list carrying the name on every
67
+ * entry. Returning nothing on unreadable output is what keeps an unreachable
68
+ * index from reporting as a clean tree, and the caller turns it into a stated
69
+ * refusal rather than a zero.
70
+ */
71
+ export function parseAdvisories(stdout: string): Advisory[] | undefined {
72
+ let record: unknown
73
+ try {
74
+ record = JSON.parse(stdout)
75
+ } catch {
76
+ return undefined
77
+ }
78
+
79
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) {
80
+ return undefined
81
+ }
82
+
83
+ const advisories: Advisory[] = []
84
+ for (const [name, entries] of Object.entries(record)) {
85
+ if (!Array.isArray(entries)) return undefined
86
+
87
+ for (const raw of entries) {
88
+ if (typeof raw !== 'object' || raw === null) return undefined
89
+ const entry = raw as Record<string, unknown>
90
+
91
+ advisories.push({
92
+ package: name,
93
+ id: typeof entry.id === 'number' ? entry.id : 0,
94
+ title: typeof entry.title === 'string' ? entry.title : '',
95
+ url: typeof entry.url === 'string' ? entry.url : '',
96
+ severity: severityOf(entry.severity),
97
+ ...(typeof entry.vulnerable_versions === 'string' && {
98
+ vulnerableVersions: entry.vulnerable_versions,
99
+ }),
100
+ })
101
+ }
102
+ }
103
+
104
+ return advisories
105
+ }
106
+
107
+ export function countBySeverity(
108
+ advisories: readonly Advisory[],
109
+ ): Record<Severity, number> {
110
+ const counts = Object.fromEntries(
111
+ SEVERITIES.map((severity) => [severity, 0]),
112
+ ) as Record<Severity, number>
113
+
114
+ for (const advisory of advisories) counts[advisory.severity] += 1
115
+ return counts
116
+ }
117
+
118
+ /**
119
+ * Shells the runtime's own advisory command rather than carrying an index.
120
+ *
121
+ * A vendored advisory database is a second corpus to keep current, and what
122
+ * this check is worth is the report rather than the data. The cost is the one
123
+ * failure mode no other audit here carries: the command reaches a network, so
124
+ * an unreachable index has to be told from a tree with nothing against it.
125
+ * That split is the return value, and the exit code is deliberately not read.
126
+ * `bun audit` exits non-zero on advisories found and on a lookup that failed,
127
+ * so the record on stdout is the only thing that separates them.
128
+ */
129
+ export async function auditDependencies(root: string): Promise<DepsAudit> {
130
+ if (!existsSync(join(root, 'package.json'))) {
131
+ return { kind: 'refused', reason: 'no-manifest' }
132
+ }
133
+
134
+ if (!LOCKFILES.some((name) => existsSync(join(root, name)))) {
135
+ return { kind: 'refused', reason: 'no-lockfile' }
136
+ }
137
+
138
+ const result = await execa('bun', ['audit', '--json'], {
139
+ cwd: root,
140
+ reject: false,
141
+ })
142
+
143
+ const advisories = parseAdvisories(result.stdout)
144
+ if (advisories === undefined) {
145
+ return {
146
+ kind: 'refused',
147
+ reason: 'no-record',
148
+ message: result.stderr.trim().split('\n').pop() ?? 'no output on stdout',
149
+ }
150
+ }
151
+
152
+ return { kind: 'audited', advisories }
153
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The inline token exempting one line from the secret scan.
3
+ *
4
+ * Shaped on the `stub: true` precedent in `src/seed-marker.ts`, which answers
5
+ * a check whose own comment records a false-positive class. The exemption
6
+ * travels with the line it exempts rather than sitting in a path list away
7
+ * from it, so a reader meeting a muted match finds the reason on the spot.
8
+ *
9
+ * The set of files carrying one is empty today. What empties it is the keying
10
+ * rather than this mechanism: `patterns.ts` matches issued values and never
11
+ * the words around them, so nothing in the shipped tree matches on purpose.
12
+ * A path allow-list was declined for the same reason, since the noise it would
13
+ * target is word-keyed and spread past the fixture trees, so it would hide
14
+ * part of the noise and none of the risk.
15
+ */
16
+ export const SECRET_MARKER = 'aitk-allow-secret'
17
+
18
+ /**
19
+ * Only a marker naming a reason counts.
20
+ *
21
+ * A bare token is read as a line that meant to say something and did not,
22
+ * which is the rule `isStubSeed` already applies to a field set to anything
23
+ * but `true`. Honoring it would let a typo mute a finding, and the reason is
24
+ * the whole value of an exemption a later reader has to weigh.
25
+ */
26
+ const MARKER_LINE = new RegExp(`${SECRET_MARKER}:[ \\t]*\\S`)
27
+
28
+ /**
29
+ * Whether the line at `index` is exempt, reading itself and the line above it.
30
+ *
31
+ * Two lines rather than one, because a credential-shaped literal is as often
32
+ * introduced by a preceding comment as annotated inline, and a format that
33
+ * takes no trailing comment at all has nowhere else to put the marker. Nothing
34
+ * further up counts, so a marker cannot silence a block it does not sit on.
35
+ */
36
+ export function isExempt(lines: readonly string[], index: number): boolean {
37
+ const own = lines[index]
38
+ const above = index > 0 ? lines[index - 1] : undefined
39
+
40
+ return (
41
+ (own !== undefined && MARKER_LINE.test(own)) ||
42
+ (above !== undefined && MARKER_LINE.test(above))
43
+ )
44
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * The rule set the secret scan keys on.
3
+ *
4
+ * Every pattern matches an issued value and none of them matches a word. That
5
+ * split is what lets the exclusion set start empty: a scan keyed on `password`,
6
+ * `secret`, or `token` fires on the environment reads, the workflow inputs, and
7
+ * the prose that name those things, and this repository ships all three. The
8
+ * cost is a credential no issuer stamps with a recognizable prefix, which this
9
+ * set does not reach and no exclusion would have helped with either.
10
+ *
11
+ * None of these sources matches itself, so this file is in scope like any
12
+ * other. Each literal prefix is followed here by a character class rather than
13
+ * by the class's own members, and `src/secrets/scan.test.ts` holds the check.
14
+ */
15
+ export interface SecretPattern {
16
+ readonly id: string
17
+ readonly label: string
18
+ /** Carries the global flag, since a line may hold more than one value. */
19
+ readonly match: RegExp
20
+ /**
21
+ * Whether the matched text is the credential itself.
22
+ *
23
+ * A private key header names a block without carrying its bytes, so echoing
24
+ * it whole tells the reader what was found. Every other pattern matches the
25
+ * value, and reporting one in full would copy a live credential into a log.
26
+ */
27
+ readonly redact: boolean
28
+ }
29
+
30
+ export const PATTERNS: readonly SecretPattern[] = [
31
+ {
32
+ id: 'aws-access-key-id',
33
+ label: 'AWS access key id',
34
+ match: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
35
+ redact: true,
36
+ },
37
+ {
38
+ id: 'github-token',
39
+ label: 'GitHub token',
40
+ // Spelled as alternation rather than a character class, so the source
41
+ // carries the issued prefixes as themselves. A class reads as one
42
+ // pronounceable token to a spell checker and puts a nonsense word in a
43
+ // dictionary that is supposed to hold real terms.
44
+ match: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b/g,
45
+ redact: true,
46
+ },
47
+ {
48
+ id: 'github-fine-grained-token',
49
+ label: 'GitHub fine-grained token',
50
+ match: /\bgithub_pat_[A-Za-z0-9_]{50,}\b/g,
51
+ redact: true,
52
+ },
53
+ {
54
+ id: 'google-api-key',
55
+ label: 'Google API key',
56
+ match: /\bAIza[0-9A-Za-z_-]{35}\b/g,
57
+ redact: true,
58
+ },
59
+ {
60
+ id: 'slack-token',
61
+ label: 'Slack token',
62
+ match: /\b(?:xoxa|xoxb|xoxp|xoxr|xoxs)-[0-9A-Za-z-]{12,}\b/g,
63
+ redact: true,
64
+ },
65
+ {
66
+ id: 'slack-webhook',
67
+ label: 'Slack webhook',
68
+ match: /https:\/\/hooks\.slack\.com\/services\/T[0-9A-Za-z_/-]{20,}/g,
69
+ redact: true,
70
+ },
71
+ {
72
+ id: 'stripe-secret-key',
73
+ label: 'Stripe live key',
74
+ match: /\b(?:sk|rk)_live_[0-9A-Za-z]{20,}\b/g,
75
+ redact: true,
76
+ },
77
+ {
78
+ id: 'anthropic-api-key',
79
+ label: 'Anthropic API key',
80
+ match: /\bsk-ant-[0-9A-Za-z_-]{24,}\b/g,
81
+ redact: true,
82
+ },
83
+ {
84
+ id: 'openai-api-key',
85
+ label: 'OpenAI project key',
86
+ match: /\bsk-proj-[0-9A-Za-z_-]{24,}\b/g,
87
+ redact: true,
88
+ },
89
+ {
90
+ id: 'npm-token',
91
+ label: 'npm token',
92
+ match: /\bnpm_[0-9A-Za-z]{36}\b/g,
93
+ redact: true,
94
+ },
95
+ {
96
+ id: 'private-key-block',
97
+ label: 'Private key block',
98
+ match: /-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----/g,
99
+ redact: false,
100
+ },
101
+ ]
102
+
103
+ export interface PatternHit {
104
+ readonly pattern: string
105
+ readonly label: string
106
+ /** One-based, so the report reads like every other file reference here. */
107
+ readonly column: number
108
+ /** What the report prints, redacted unless the pattern says otherwise. */
109
+ readonly preview: string
110
+ }
111
+
112
+ /**
113
+ * Shortens a matched value to its two ends.
114
+ *
115
+ * The ends are what a reader needs to find the credential in the file and to
116
+ * tell one match from another, and the middle is the part no report should
117
+ * carry. A value too short to have a middle is reported as its shape alone.
118
+ */
119
+ function redact(value: string): string {
120
+ if (value.length <= 8) return '…'
121
+ return `${value.slice(0, 4)}…${value.slice(-4)}`
122
+ }
123
+
124
+ /** Every value on one line, ordered by where each starts. */
125
+ export function matchLine(line: string): PatternHit[] {
126
+ const hits: PatternHit[] = []
127
+
128
+ for (const pattern of PATTERNS) {
129
+ for (const found of line.matchAll(pattern.match)) {
130
+ if (found.index === undefined) continue
131
+
132
+ hits.push({
133
+ pattern: pattern.id,
134
+ label: pattern.label,
135
+ column: found.index + 1,
136
+ preview: pattern.redact ? redact(found[0]) : found[0],
137
+ })
138
+ }
139
+ }
140
+
141
+ return hits.sort((left, right) => left.column - right.column)
142
+ }