@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/docs/agents/audits.md +14 -4
- package/docs/agents/commands.md +46 -42
- package/docs/agents/index.md +1 -0
- package/docs/agents/scripting.md +15 -0
- package/docs/agents/state-scoped-risk.md +105 -0
- package/package.json +1 -1
- package/src/audits/baseline.ts +8 -1
- package/src/audits/catalog.ts +128 -9
- package/src/audits/run.ts +1 -0
- package/src/cli.ts +8 -0
- package/src/commands/audits.ts +11 -3
- package/src/commands/deps.ts +173 -0
- package/src/commands/secrets.ts +132 -0
- package/src/deps/audit.ts +153 -0
- package/src/secrets/marker.ts +44 -0
- package/src/secrets/patterns.ts +142 -0
- package/src/secrets/scan.ts +134 -0
- package/src/secrets/shipped.ts +111 -0
|
@@ -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
|
+
}
|