@erclx/aitk 2.1.0 → 3.0.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.
@@ -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
+ }
@@ -0,0 +1,134 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import { listRepositoryFiles } from '@/git-files'
4
+ import { isExempt } from '@/secrets/marker'
5
+ import { matchLine } from '@/secrets/patterns'
6
+ import { readShipEntries, selectShipped } from '@/secrets/shipped'
7
+
8
+ export interface SecretFinding {
9
+ readonly file: string
10
+ /** One-based, matching the `file:line` form a reader clicks. */
11
+ readonly line: number
12
+ readonly column: number
13
+ readonly pattern: string
14
+ readonly label: string
15
+ readonly preview: string
16
+ }
17
+
18
+ /**
19
+ * Why a scan produced no corpus, which is never the same as a clean one.
20
+ *
21
+ * Three of these mean this tree publishes nothing, and `no-files-field` means
22
+ * the opposite: a publish would pack everything and this check read none of it.
23
+ * They are separate reasons because the aggregate answers them differently,
24
+ * and folding them together is what let one message deny the case another
25
+ * comment named.
26
+ */
27
+ export type ScanRefusal =
28
+ | 'no-manifest'
29
+ | 'no-publish'
30
+ | 'no-files-field'
31
+ | 'no-git'
32
+ | 'no-shipped-files'
33
+
34
+ export type SecretScan =
35
+ | {
36
+ readonly kind: 'scanned'
37
+ /** Files opened, so a report can state what the verdict covers. */
38
+ readonly files: number
39
+ readonly skipped: number
40
+ /**
41
+ * Everything git lists, so the report states its own bound.
42
+ *
43
+ * A count of what passed reads as a verdict on the repository unless the
44
+ * run also says how much of it the corpus left out.
45
+ */
46
+ readonly listed: number
47
+ readonly findings: readonly SecretFinding[]
48
+ }
49
+ | { readonly kind: 'refused'; readonly reason: ScanRefusal }
50
+
51
+ /**
52
+ * Whether the bytes are something a line scanner should not read.
53
+ *
54
+ * A NUL byte rather than an extension list, since the shipped tree carries
55
+ * fonts and images under names this check has no reason to enumerate, and a
56
+ * list would go stale the first time a format was added. Decoded text holds no
57
+ * NUL, so the test costs one scan and never rejects source.
58
+ */
59
+ export function isBinary(text: string): boolean {
60
+ return text.includes('\0')
61
+ }
62
+
63
+ /** Every finding in one file's text, with the marker already applied. */
64
+ export function scanText(file: string, text: string): SecretFinding[] {
65
+ const lines = text.split('\n')
66
+ const findings: SecretFinding[] = []
67
+
68
+ for (const [index, line] of lines.entries()) {
69
+ const hits = matchLine(line)
70
+ if (hits.length === 0 || isExempt(lines, index)) continue
71
+
72
+ for (const hit of hits) {
73
+ findings.push({ file, line: index + 1, ...hit })
74
+ }
75
+ }
76
+
77
+ return findings
78
+ }
79
+
80
+ /**
81
+ * Scans the tree this repository publishes, not the tree it holds.
82
+ *
83
+ * A refusal rather than an empty result wherever the corpus cannot be built.
84
+ * Zero findings over zero files reads in the report exactly like zero findings
85
+ * over the whole shipped tree, and the two mean opposite things, which is the
86
+ * split every other audit here already draws.
87
+ */
88
+ export async function scanShippedTree(root: string): Promise<SecretScan> {
89
+ const declared = await readShipEntries(root)
90
+ if (declared.kind !== 'entries') {
91
+ return { kind: 'refused', reason: declared.kind }
92
+ }
93
+
94
+ const listed = await listRepositoryFiles(root)
95
+ if (listed === undefined) return { kind: 'refused', reason: 'no-git' }
96
+
97
+ const paths = selectShipped(listed, declared.entries)
98
+ if (paths.length === 0) {
99
+ return { kind: 'refused', reason: 'no-shipped-files' }
100
+ }
101
+
102
+ const findings: SecretFinding[] = []
103
+ let scanned = 0
104
+ let skipped = 0
105
+
106
+ for (const path of paths) {
107
+ let text: string
108
+ try {
109
+ text = await readFile(join(root, path), 'utf8')
110
+ } catch {
111
+ // A listed path that will not open is a symlink pointing outside the
112
+ // tree or a file removed since git answered. Counted rather than
113
+ // reported, so the run still states that it measured less than it listed.
114
+ skipped += 1
115
+ continue
116
+ }
117
+
118
+ if (isBinary(text)) {
119
+ skipped += 1
120
+ continue
121
+ }
122
+
123
+ scanned += 1
124
+ findings.push(...scanText(path, text))
125
+ }
126
+
127
+ return {
128
+ kind: 'scanned',
129
+ files: scanned,
130
+ skipped,
131
+ listed: listed.length,
132
+ findings,
133
+ }
134
+ }
@@ -0,0 +1,111 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * The corpus is the package's own `files` field rather than a list kept here.
6
+ *
7
+ * That field is what npm packs, so it is already the single statement of which
8
+ * trees leave this repository, and a second list beside it would answer the
9
+ * same question and drift. It also carries the negations the publish already
10
+ * makes, so the sandbox tree, the eval tree, and every test file are out of
11
+ * scope by the same rule that keeps them out of the tarball rather than by an
12
+ * exclusion this check invented.
13
+ *
14
+ * What it does not cover is the plugin, which a marketplace install reads live
15
+ * from `claude/` rather than from a tarball. That folder is a `files` entry
16
+ * too, and its `standards` and `snippets` symlinks resolve into trees the field
17
+ * lists in their own right, so both routes land inside the same corpus.
18
+ */
19
+ export type ShipEntries =
20
+ /** The field declares a corpus, which is what this check reads. */
21
+ | { readonly kind: 'entries'; readonly entries: readonly string[] }
22
+ /** No manifest at all, so nothing is published from this tree. */
23
+ | { readonly kind: 'no-manifest' }
24
+ /** The manifest declares it is never published, so there is no shipped tree. */
25
+ | { readonly kind: 'no-publish' }
26
+ /**
27
+ * A manifest that publishes and declares no corpus.
28
+ *
29
+ * npm packs the whole tree in that case, so this is the package that ships
30
+ * the most rather than one that ships nothing. This check reads a declared
31
+ * corpus and does not stand in an undeclared one, so the caller reports the
32
+ * shipped tree as unread rather than as empty.
33
+ */
34
+ | { readonly kind: 'no-files-field' }
35
+
36
+ export async function readShipEntries(root: string): Promise<ShipEntries> {
37
+ let manifest: unknown
38
+ try {
39
+ manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
40
+ } catch {
41
+ return { kind: 'no-manifest' }
42
+ }
43
+
44
+ const record = manifest as { files?: unknown; private?: unknown } | null
45
+
46
+ // The one field that separates a project publishing nothing from one
47
+ // publishing everything, which the `files` field alone cannot tell apart.
48
+ if (record?.private === true) return { kind: 'no-publish' }
49
+
50
+ const files = record?.files
51
+ if (!Array.isArray(files)) return { kind: 'no-files-field' }
52
+
53
+ const entries = files.filter(
54
+ (entry): entry is string => typeof entry === 'string',
55
+ )
56
+
57
+ return entries.length === 0
58
+ ? { kind: 'no-files-field' }
59
+ : { kind: 'entries', entries }
60
+ }
61
+
62
+ /**
63
+ * Root files npm packs whether or not the `files` field names them.
64
+ *
65
+ * They leave the repository on every publish, so a corpus built from the field
66
+ * alone would let a credential in any of them ship unreported. Matched by stem
67
+ * against any extension, since each is packed under whichever one it carries.
68
+ */
69
+ const ALWAYS_PACKED = ['package.json', 'readme', 'license', 'licence', 'notice']
70
+
71
+ function isAlwaysPacked(path: string): boolean {
72
+ if (path.includes('/')) return false
73
+
74
+ const stem = path.toLowerCase().split('.')[0] ?? ''
75
+ return (
76
+ ALWAYS_PACKED.includes(path.toLowerCase()) || ALWAYS_PACKED.includes(stem)
77
+ )
78
+ }
79
+
80
+ /** Whether the entry reaches this path, as a directory prefix or as a glob. */
81
+ function covers(entry: string, path: string): boolean {
82
+ if (entry.includes('*')) return new Bun.Glob(entry).match(path)
83
+
84
+ return path === entry || path.startsWith(`${entry}/`)
85
+ }
86
+
87
+ /**
88
+ * Narrows a repository listing to what the package publishes.
89
+ *
90
+ * Negations are collected first and applied to every candidate, since npm
91
+ * reads the field as one set rather than in order, and an entry's position in
92
+ * the array says nothing about what it overrides.
93
+ */
94
+ export function selectShipped(
95
+ paths: readonly string[],
96
+ entries: readonly string[],
97
+ ): string[] {
98
+ const included = entries.filter((entry) => !entry.startsWith('!'))
99
+ const excluded = entries
100
+ .filter((entry) => entry.startsWith('!'))
101
+ .map((entry) => entry.slice(1))
102
+
103
+ return paths
104
+ .filter(
105
+ (path) =>
106
+ (isAlwaysPacked(path) ||
107
+ included.some((entry) => covers(entry, path))) &&
108
+ !excluded.some((entry) => covers(entry, path)),
109
+ )
110
+ .sort()
111
+ }
package/src/sync/stamp.ts CHANGED
@@ -53,7 +53,7 @@ export interface Stamp {
53
53
  }
54
54
 
55
55
  export function stampPath(target: string): string {
56
- return join(target, '.claude', 'aitk.json')
56
+ return join(target, '.claude', 'aitk', 'config.json')
57
57
  }
58
58
 
59
59
  export function hashContent(content: Buffer | string): string {