@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.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-pr/SKILL.md +2 -2
- package/claude/skills/git-pr/references/labels.md +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/install-and-sync.md +6 -4
- package/docs/agents/scripting.md +15 -0
- package/docs/agents/state-scoped-risk.md +105 -0
- package/docs/target-projects.md +1 -1
- 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
- package/src/sync/stamp.ts +1 -1
package/src/audits/run.ts
CHANGED
|
@@ -85,6 +85,7 @@ export async function runAudits(
|
|
|
85
85
|
label: spec.label,
|
|
86
86
|
status: 'unmeasured' as const,
|
|
87
87
|
tracked: spec.corpus === 'tracked',
|
|
88
|
+
corpus: spec.corpus,
|
|
88
89
|
exitCode: 1,
|
|
89
90
|
reason: `could not be started: ${error instanceof Error ? error.message : String(error)}`,
|
|
90
91
|
}
|
package/src/cli.ts
CHANGED
|
@@ -26,6 +26,8 @@ import { register as markdown } from '@/commands/markdown'
|
|
|
26
26
|
import { register as records } from '@/commands/records'
|
|
27
27
|
import { register as sessions } from '@/commands/sessions'
|
|
28
28
|
import { register as audits } from '@/commands/audits'
|
|
29
|
+
import { register as secrets } from '@/commands/secrets'
|
|
30
|
+
import { register as deps } from '@/commands/deps'
|
|
29
31
|
import { register as upgrade } from '@/commands/upgrade'
|
|
30
32
|
import { readInstalled, UNKNOWN_LABEL } from '@/version/installed'
|
|
31
33
|
import { palette } from '@/ui'
|
|
@@ -63,6 +65,8 @@ function showHelp(): void {
|
|
|
63
65
|
`${GREY}│${NC} markdown [cmd] ${GREY}# Report markdown against the attribute standards (audit)${NC}`,
|
|
64
66
|
`${GREY}│${NC} records [cmd] ${GREY}# Session records under .claude/ (validate, size, push, pull)${NC}`,
|
|
65
67
|
`${GREY}│${NC} sessions [cmd] ${GREY}# Resolve live sessions to worktree and branch (list)${NC}`,
|
|
68
|
+
`${GREY}│${NC} secrets [cmd] ${GREY}# Read the shipped tree for credential-shaped values (scan)${NC}`,
|
|
69
|
+
`${GREY}│${NC} deps [cmd] ${GREY}# Read the resolved dependency set for advisories (audit)${NC}`,
|
|
66
70
|
`${GREY}│${NC} audits [cmd] ${GREY}# Run every health check as one set (run, list)${NC}`,
|
|
67
71
|
`${GREY}│${NC} upgrade ${GREY}# Reinstall the CLI globally with the manager that installed it${NC}`,
|
|
68
72
|
`${GREY}│${NC}`,
|
|
@@ -104,6 +108,8 @@ function showHelp(): void {
|
|
|
104
108
|
`${GREY}│${NC} aitk records size --json`,
|
|
105
109
|
`${GREY}│${NC} aitk records push --json`,
|
|
106
110
|
`${GREY}│${NC} aitk sessions list --json`,
|
|
111
|
+
`${GREY}│${NC} aitk secrets scan --json`,
|
|
112
|
+
`${GREY}│${NC} aitk deps audit --json`,
|
|
107
113
|
`${GREY}│${NC} aitk audits run --json`,
|
|
108
114
|
`${GREY}│${NC} aitk upgrade --json`,
|
|
109
115
|
`${GREY}└${NC}`,
|
|
@@ -148,6 +154,8 @@ context(program)
|
|
|
148
154
|
markdown(program)
|
|
149
155
|
records(program)
|
|
150
156
|
sessions(program)
|
|
157
|
+
secrets(program)
|
|
158
|
+
deps(program)
|
|
151
159
|
audits(program)
|
|
152
160
|
upgrade(program)
|
|
153
161
|
|
package/src/commands/audits.ts
CHANGED
|
@@ -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 (
|
|
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(
|
|
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
|
|
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
|
+
}
|