@erclx/aitk 2.0.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/scripts/lib/sandbox-git.sh +56 -4
- 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/audits/catalog.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { AuditRefusal } from '@/deps/audit'
|
|
1
2
|
import type { ValidateRefusal as RecordRefusal } from '@/records/validate'
|
|
3
|
+
import type { ScanRefusal } from '@/secrets/scan'
|
|
2
4
|
import type { ValidateRefusal as BoardRefusal } from '@/tasks/validate'
|
|
3
5
|
|
|
4
6
|
/**
|
|
@@ -16,6 +18,17 @@ export type Corpus =
|
|
|
16
18
|
| 'tracked'
|
|
17
19
|
/** Gitignored session scratch, so the numbers are one machine's alone. */
|
|
18
20
|
| 'per-machine'
|
|
21
|
+
/**
|
|
22
|
+
* Read from an index off this machine, so the count moves when someone
|
|
23
|
+
* publishes rather than when someone edits here.
|
|
24
|
+
*
|
|
25
|
+
* Its own member rather than either of the two above. A baseline would
|
|
26
|
+
* record growth nobody caused, which is what keeps it out of the retained
|
|
27
|
+
* set alongside per-machine scratch, and an index this run could not reach
|
|
28
|
+
* is an ordinary absence rather than the broken checkout a missing tracked
|
|
29
|
+
* tree would be.
|
|
30
|
+
*/
|
|
31
|
+
| 'upstream'
|
|
19
32
|
|
|
20
33
|
export type AuditStatus =
|
|
21
34
|
/** The audit reported and every count it produced is zero. */
|
|
@@ -47,6 +60,17 @@ export interface AuditSpec {
|
|
|
47
60
|
*/
|
|
48
61
|
readonly gatingExits: readonly number[]
|
|
49
62
|
readonly corpus: Corpus
|
|
63
|
+
/**
|
|
64
|
+
* Refusal reasons that mean this audit has no corpus here rather than that
|
|
65
|
+
* it broke, overriding what the corpus alone would allow.
|
|
66
|
+
*
|
|
67
|
+
* Present only where the corpus answers wrongly. A tracked corpus normally
|
|
68
|
+
* allows nothing, since a tree that ships to targets and cannot be found is
|
|
69
|
+
* a broken checkout, and the secret scan is the exception: a project that
|
|
70
|
+
* publishes nothing has no shipped tree to read, which is an ordinary state
|
|
71
|
+
* rather than a defect.
|
|
72
|
+
*/
|
|
73
|
+
readonly absentReasons?: readonly string[]
|
|
50
74
|
/**
|
|
51
75
|
* Pulls the counts worth retaining out of this verb's record.
|
|
52
76
|
*
|
|
@@ -62,7 +86,10 @@ export interface AuditResult {
|
|
|
62
86
|
readonly id: string
|
|
63
87
|
readonly label: string
|
|
64
88
|
readonly status: AuditStatus
|
|
89
|
+
/** Retained by the baseline, which is `tracked` alone. */
|
|
65
90
|
readonly tracked: boolean
|
|
91
|
+
/** Why it is or is not retained, which `tracked` alone cannot say. */
|
|
92
|
+
readonly corpus: Corpus
|
|
66
93
|
readonly exitCode: number
|
|
67
94
|
readonly counts?: Record<string, number>
|
|
68
95
|
/** Why the audit did not report, present only on `unmeasured`. */
|
|
@@ -271,6 +298,26 @@ function boardCounts(record: unknown): Record<string, number> | undefined {
|
|
|
271
298
|
})
|
|
272
299
|
}
|
|
273
300
|
|
|
301
|
+
/**
|
|
302
|
+
* Reads the advisory verb's per-severity object rather than a total.
|
|
303
|
+
*
|
|
304
|
+
* A single count folds a critical advisory into a low one, and the response to
|
|
305
|
+
* the two differs. The keys are read off the record rather than listed here,
|
|
306
|
+
* so a severity the index adds is carried instead of silently dropped.
|
|
307
|
+
*/
|
|
308
|
+
function advisoryCounts(record: unknown): Record<string, number> | undefined {
|
|
309
|
+
const severities = asObject(asObject(record)?.severities)
|
|
310
|
+
if (severities === undefined) return undefined
|
|
311
|
+
|
|
312
|
+
const counts: Record<string, number> = {}
|
|
313
|
+
for (const [severity, value] of Object.entries(severities)) {
|
|
314
|
+
if (typeof value !== 'number') return undefined
|
|
315
|
+
counts[`advisories-${severity}`] = value
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return counts
|
|
319
|
+
}
|
|
320
|
+
|
|
274
321
|
function findingsOnly(record: unknown): Record<string, number> | undefined {
|
|
275
322
|
const root = asObject(record)
|
|
276
323
|
if (root === undefined) return undefined
|
|
@@ -318,10 +365,12 @@ const RECORD_KINDS: readonly (readonly [string, Corpus])[] = [
|
|
|
318
365
|
/**
|
|
319
366
|
* Every audit the aggregate runs.
|
|
320
367
|
*
|
|
321
|
-
* `context`, `markdown`, and `skills`
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
368
|
+
* `context`, `markdown`, and `skills` gate because `scripts/core/verify.sh`
|
|
369
|
+
* already fails a push on each. `secrets` is the one entry that gates without
|
|
370
|
+
* a stage behind it, added deliberately rather than as a side effect, since a
|
|
371
|
+
* credential in the published tree is a fact and the split this repository
|
|
372
|
+
* records gates a fact and reports a judgment. Weigh any further addition
|
|
373
|
+
* against that test rather than against the count.
|
|
325
374
|
*
|
|
326
375
|
* Each verb runs once in its fullest form. Running the gating half separately
|
|
327
376
|
* would walk the same tree twice for a number the full record already carries.
|
|
@@ -386,6 +435,46 @@ export const AUDITS: readonly AuditSpec[] = [
|
|
|
386
435
|
corpus: 'tracked',
|
|
387
436
|
counts: testOrderCounts,
|
|
388
437
|
},
|
|
438
|
+
{
|
|
439
|
+
id: 'secrets',
|
|
440
|
+
label: 'Shipped tree secrets',
|
|
441
|
+
argv: ['secrets', 'scan', '--json'],
|
|
442
|
+
// The fourth gate, and the first added since the note above was written.
|
|
443
|
+
// A credential-shaped value sitting in the tree this repository publishes
|
|
444
|
+
// is a fact rather than a judgment, which is the test that note asks any
|
|
445
|
+
// addition to pass. It is decided here and stated in the context entry
|
|
446
|
+
// rather than arriving as a side effect of registering a measure.
|
|
447
|
+
gatingExits: [EXIT_FINDINGS],
|
|
448
|
+
corpus: 'tracked',
|
|
449
|
+
// The three reasons that mean this tree publishes nothing, which is where
|
|
450
|
+
// most targets installing this CLI sit. Without the allowance the
|
|
451
|
+
// aggregate reports `incomplete` on every run in every such project and
|
|
452
|
+
// never changes, which is the permanent signal the per-machine allowance
|
|
453
|
+
// exists against.
|
|
454
|
+
//
|
|
455
|
+
// Two reasons are deliberately left out, and both are a corpus that exists
|
|
456
|
+
// and went unread. `no-git` is a broken checkout, and `no-files-field` is a
|
|
457
|
+
// publish that would pack the whole tree, so calling either an absence
|
|
458
|
+
// would report a pass over a shipped tree nobody measured.
|
|
459
|
+
absentReasons: [
|
|
460
|
+
'no-manifest',
|
|
461
|
+
'no-publish',
|
|
462
|
+
'no-shipped-files',
|
|
463
|
+
] satisfies ScanRefusal[],
|
|
464
|
+
counts: findingsOnly,
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
id: 'deps',
|
|
468
|
+
label: 'Dependency advisories',
|
|
469
|
+
argv: ['deps', 'audit', '--json'],
|
|
470
|
+
// Reports rather than gates, on the same split. A published advisory is a
|
|
471
|
+
// fact about the index and a judgment about this tree, since the upgrade
|
|
472
|
+
// may not exist yet, and a push failing on one teaches a contributor to
|
|
473
|
+
// route around the stage while nothing about the dependency has changed.
|
|
474
|
+
gatingExits: [],
|
|
475
|
+
corpus: 'upstream',
|
|
476
|
+
counts: advisoryCounts,
|
|
477
|
+
},
|
|
389
478
|
]
|
|
390
479
|
|
|
391
480
|
export function auditFor(id: string): AuditSpec | undefined {
|
|
@@ -432,6 +521,25 @@ const ABSENT_REASONS: readonly (RecordRefusal | BoardRefusal)[] = [
|
|
|
432
521
|
'no-board',
|
|
433
522
|
]
|
|
434
523
|
|
|
524
|
+
/**
|
|
525
|
+
* Every reason the advisory verb refuses for, all of which are an absence.
|
|
526
|
+
*
|
|
527
|
+
* An index it could not reach, a project that is not JavaScript, and one whose
|
|
528
|
+
* dependencies were never resolved are three states in which there is nothing
|
|
529
|
+
* to measure rather than something broken. The verb has no fourth reason, so
|
|
530
|
+
* this is its whole union rather than a chosen subset, and typing it that way
|
|
531
|
+
* fails the build if a later reason arrives without this decision being made.
|
|
532
|
+
*
|
|
533
|
+
* Typed against that module's own union for the reason the record reasons are:
|
|
534
|
+
* a literal here would go on matching nothing after a rename and turn every
|
|
535
|
+
* offline run back into an unmeasured audit.
|
|
536
|
+
*/
|
|
537
|
+
const ADVISORY_ABSENT_REASONS: readonly AuditRefusal[] = [
|
|
538
|
+
'no-record',
|
|
539
|
+
'no-lockfile',
|
|
540
|
+
'no-manifest',
|
|
541
|
+
]
|
|
542
|
+
|
|
435
543
|
/**
|
|
436
544
|
* Whether a refusal is a folder this machine never created rather than a break.
|
|
437
545
|
*
|
|
@@ -443,15 +551,25 @@ const ABSENT_REASONS: readonly (RecordRefusal | BoardRefusal)[] = [
|
|
|
443
551
|
* A tracked corpus gets no such allowance. That tree ships to targets, so a
|
|
444
552
|
* checkout that cannot find it is broken, and reading the absence as ordinary
|
|
445
553
|
* would report a pass over a corpus nobody measured.
|
|
554
|
+
*
|
|
555
|
+
* An upstream corpus takes the allowance for a different reason. Its index is
|
|
556
|
+
* off this machine, so an offline run reaches nothing through no fault of the
|
|
557
|
+
* tree, and pinning the verdict at incomplete every time the network is down
|
|
558
|
+
* is the same signal-nobody-reads failure the per-machine case already names.
|
|
446
559
|
*/
|
|
560
|
+
function absentReasonsFor(spec: AuditSpec): readonly string[] {
|
|
561
|
+
if (spec.absentReasons !== undefined) return spec.absentReasons
|
|
562
|
+
if (spec.corpus === 'per-machine') return ABSENT_REASONS
|
|
563
|
+
if (spec.corpus === 'upstream') return ADVISORY_ABSENT_REASONS
|
|
564
|
+
return []
|
|
565
|
+
}
|
|
566
|
+
|
|
447
567
|
function isExpectedAbsence(spec: AuditSpec, record: unknown): boolean {
|
|
448
|
-
|
|
568
|
+
const allowed = absentReasonsFor(spec)
|
|
569
|
+
if (allowed.length === 0) return false
|
|
449
570
|
|
|
450
571
|
const reason = asObject(record)?.reason
|
|
451
|
-
return (
|
|
452
|
-
typeof reason === 'string' &&
|
|
453
|
-
(ABSENT_REASONS as readonly string[]).includes(reason)
|
|
454
|
-
)
|
|
572
|
+
return typeof reason === 'string' && allowed.includes(reason)
|
|
455
573
|
}
|
|
456
574
|
|
|
457
575
|
/**
|
|
@@ -471,6 +589,7 @@ export function classify(
|
|
|
471
589
|
id: spec.id,
|
|
472
590
|
label: spec.label,
|
|
473
591
|
tracked: isTracked(spec),
|
|
592
|
+
corpus: spec.corpus,
|
|
474
593
|
exitCode,
|
|
475
594
|
}
|
|
476
595
|
|
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
|
+
}
|