@mstar-harness/engine 3.3.0 → 3.4.1
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/dist/audit.d.ts +110 -1
- package/dist/audit.js +345 -15
- package/dist/dispatch.d.ts +37 -18
- package/dist/engine.js +1141 -78
- package/dist/index.d.ts +11 -6
- package/dist/project.d.ts +57 -0
- package/dist/prreview.d.ts +335 -0
- package/package.json +1 -1
package/dist/audit.d.ts
CHANGED
|
@@ -8,6 +8,11 @@ export type AuditEffort = (typeof AUDIT_EFFORTS)[number];
|
|
|
8
8
|
/** Risk values. */
|
|
9
9
|
export declare const AUDIT_RISKS: readonly ["LOW", "MED", "HIGH"];
|
|
10
10
|
export type AuditRisk = (typeof AUDIT_RISKS)[number];
|
|
11
|
+
/** Confidence values (finding-format.md § Template — Confidence: HIGH
|
|
12
|
+
* (certain, read the code) / MED (strong signal, verify) / LOW (smell,
|
|
13
|
+
* investigate)). Enum SSOT for finding-doc lint + AuditFinding. */
|
|
14
|
+
export declare const AUDIT_CONFIDENCES: readonly ["HIGH", "MED", "LOW"];
|
|
15
|
+
export type AuditConfidence = (typeof AUDIT_CONFIDENCES)[number];
|
|
11
16
|
/** Category codes (finding-format.md § Category codes + mstar-audit SKILL.md § Plan output (all variants) Status block). */
|
|
12
17
|
export declare const AUDIT_CATEGORIES: readonly ["bug", "security", "perf", "tests", "tech-debt", "migration", "dx", "docs", "direction"];
|
|
13
18
|
export type AuditCategory = (typeof AUDIT_CATEGORIES)[number];
|
|
@@ -42,6 +47,55 @@ export type RedactResult = {
|
|
|
42
47
|
text: string;
|
|
43
48
|
findings: SecretFinding[];
|
|
44
49
|
};
|
|
50
|
+
/**
|
|
51
|
+
* Whole-match credential patterns — the match is fully replaced. Patterns
|
|
52
|
+
* are deliberately conservative (prefixed signatures + minimum lengths) to
|
|
53
|
+
* avoid false positives (mstar-audit Hard Rule 4: reference file:line and
|
|
54
|
+
* credential type only, never the value). Exported as the D-2 SSOT: both
|
|
55
|
+
* `redactSecrets` and `scanSecrets` share this one table, so reviewers can
|
|
56
|
+
* reconcile against exactly what the engine scans. Extending it only ever
|
|
57
|
+
* ADDS detections for consumers of either reader — existing-pattern
|
|
58
|
+
* behavior of the dsh audit seam (`validateAuditDoc`) stays byte-identical.
|
|
59
|
+
*/
|
|
60
|
+
export declare const WHOLE_MATCH_PATTERNS: readonly {
|
|
61
|
+
type: string;
|
|
62
|
+
re: RegExp;
|
|
63
|
+
}[];
|
|
64
|
+
/**
|
|
65
|
+
* Key-value assignment patterns — for redaction the key name is preserved
|
|
66
|
+
* and only the value is replaced. Value minimum lengths (8 quoted / 16
|
|
67
|
+
* unquoted) keep the scan conservative (`token: x` and `password: 1234`
|
|
68
|
+
* are not flagged). Keys may carry optional quotes (JSON/YAML
|
|
69
|
+
* `"password": "..."`), which are preserved in the replacement: group 1 =
|
|
70
|
+
* optional open quote, group 3 = optional close quote, group 4 = separator,
|
|
71
|
+
* group 5 = value (dropped). Exported as part of the D-2 SSOT — shared by
|
|
72
|
+
* `redactSecrets` and `scanSecrets`.
|
|
73
|
+
*/
|
|
74
|
+
export declare const VALUE_PATTERNS: readonly {
|
|
75
|
+
typeOf: (key: string) => string;
|
|
76
|
+
re: RegExp;
|
|
77
|
+
}[];
|
|
78
|
+
/**
|
|
79
|
+
* Never-commit filename patterns (security-review.md §6): a file whose name
|
|
80
|
+
* matches should not be committed regardless of content — `.env*`, `*.pem`,
|
|
81
|
+
* `*.key`, `id_rsa`-family, plus named credential stores. All patterns are
|
|
82
|
+
* matched against the path BASENAME, never the full path.
|
|
83
|
+
*/
|
|
84
|
+
export declare const NEVER_COMMIT_FILENAMES: readonly {
|
|
85
|
+
type: string;
|
|
86
|
+
re: RegExp;
|
|
87
|
+
}[];
|
|
88
|
+
/**
|
|
89
|
+
* CI/IaC credential-leak shapes (security-review.md §6). Each entry is one
|
|
90
|
+
* deterministic line shape; match groups feed the finding message. All
|
|
91
|
+
* shapes are additive on top of the pattern tables above and never capture
|
|
92
|
+
* secret VALUES into findings (Hard Rule 4) — only file:line + type.
|
|
93
|
+
*/
|
|
94
|
+
export declare const CI_IAC_LEAK_SHAPES: readonly {
|
|
95
|
+
kind: string;
|
|
96
|
+
description: string;
|
|
97
|
+
re: RegExp;
|
|
98
|
+
}[];
|
|
45
99
|
/**
|
|
46
100
|
* Scan text for credential patterns and replace every occurrence with a
|
|
47
101
|
* `[REDACTED <type>@<line> in <file>]` marker (file omitted when `filePath`
|
|
@@ -50,6 +104,61 @@ export type RedactResult = {
|
|
|
50
104
|
* line-sorted findings summary (`{ line, type }`).
|
|
51
105
|
*/
|
|
52
106
|
export declare function redactSecrets(text: string, filePath?: string): RedactResult;
|
|
107
|
+
/** One `scanSecrets` finding: file + 1-based line + credential type ONLY —
|
|
108
|
+
* never the secret value (mstar-audit Hard Rule 4). */
|
|
109
|
+
export type ScannedSecret = {
|
|
110
|
+
file: string;
|
|
111
|
+
line: number;
|
|
112
|
+
type: string;
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Read-only per-line secret scan over the given files. Report-only by
|
|
116
|
+
* design — it never redacts, never writes, and its findings carry
|
|
117
|
+
* `file:line` + credential type only (Hard Rule 4). Patterns come from the
|
|
118
|
+
* D-2 SSOT tables above (`WHOLE_MATCH_PATTERNS`, `VALUE_PATTERNS`) plus the
|
|
119
|
+
* never-commit filename list (`NEVER_COMMIT_FILENAMES`) and CI/IaC leak
|
|
120
|
+
* shapes (`CI_IAC_LEAK_SHAPES`). Safe-placeholder spans are masked before
|
|
121
|
+
* pattern evaluation (only the span — a key beside it still fires). Files
|
|
122
|
+
* that cannot be read are counted in `unreadableFiles` instead of being
|
|
123
|
+
* silently skipped: a security gate must never report clean over input it
|
|
124
|
+
* could not inspect (qc1 W-002).
|
|
125
|
+
*/
|
|
126
|
+
/** Result of {@link scanSecrets}: findings plus how many selected files
|
|
127
|
+
* could not be read (fail-closed signal for CLI gates). */
|
|
128
|
+
export type ScanSecretsResult = {
|
|
129
|
+
findings: ScannedSecret[];
|
|
130
|
+
unreadableFiles: number;
|
|
131
|
+
};
|
|
132
|
+
export declare function scanSecrets(files: readonly string[]): ScanSecretsResult;
|
|
133
|
+
/** Kinds of supply-chain findings (B-11): lockfile presence/duplication at
|
|
134
|
+
* install boundaries and GitHub Actions pin/exposure shapes. */
|
|
135
|
+
export type SupplyChainFindingKind = "lockfile-missing" | "lockfile-duplicate" | "action-unpinned" | "pull_request_target-head";
|
|
136
|
+
export type SupplyChainFinding = {
|
|
137
|
+
kind: SupplyChainFindingKind;
|
|
138
|
+
file: string;
|
|
139
|
+
line?: number;
|
|
140
|
+
};
|
|
141
|
+
/** Result of {@link supplyChainChecks}: gate verdict + machine findings. */
|
|
142
|
+
export type SupplyChainResult = GateResult & {
|
|
143
|
+
findings: SupplyChainFinding[];
|
|
144
|
+
};
|
|
145
|
+
/**
|
|
146
|
+
* Deterministic supply-chain checks over `repoRoot` (read-only):
|
|
147
|
+
* - `lockfile-missing`: no recognized lockfile at the repo root
|
|
148
|
+
* (package-lock.json / pnpm-lock.yaml / yarn.lock / bun.lock[b] /
|
|
149
|
+
* Cargo.lock / poetry.lock / uv.lock / Gemfile.lock / composer.lock).
|
|
150
|
+
* - `lockfile-duplicate`: two or more distinct lockfiles at the root —
|
|
151
|
+
* ambiguous install boundaries.
|
|
152
|
+
* - `action-unpinned`: `.github/workflows/*.yml` steps using mutable refs
|
|
153
|
+
* (`@main`, `@master`, `@latest` or any non-SHA ref).
|
|
154
|
+
* - `pull_request_target-head`: a workflow triggers on `pull_request_target`
|
|
155
|
+
* AND checks out the PR head — untrusted code with secrets access.
|
|
156
|
+
*
|
|
157
|
+
* Tri-age judgment (reachable / runtime-relevant) is deliberately left to
|
|
158
|
+
* the reviewer (C-class). Violation codes mirror finding kinds with the
|
|
159
|
+
* `audit.supply.` prefix; `ok` is false iff any finding exists.
|
|
160
|
+
*/
|
|
161
|
+
export declare function supplyChainChecks(repoRoot: string): SupplyChainResult;
|
|
53
162
|
/** One audit finding, shaped after finding-format.md. */
|
|
54
163
|
export type AuditFinding = {
|
|
55
164
|
title: string;
|
|
@@ -57,7 +166,7 @@ export type AuditFinding = {
|
|
|
57
166
|
impact: string;
|
|
58
167
|
effort: AuditEffort;
|
|
59
168
|
risk: AuditRisk;
|
|
60
|
-
confidence:
|
|
169
|
+
confidence: AuditConfidence;
|
|
61
170
|
evidence: readonly string[];
|
|
62
171
|
priority: AuditPriority;
|
|
63
172
|
fixSketch?: string;
|
package/dist/audit.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/audit.ts
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
2
3
|
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readdirSync as readdirSync2, readFileSync as readFileSync3, rmdirSync as rmdirSync2, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
3
4
|
import { basename as basename2, join as join4, resolve as resolve4, sep as sep2 } from "node:path";
|
|
4
5
|
|
|
@@ -343,6 +344,7 @@ function violation2(severity, code, message, fix) {
|
|
|
343
344
|
var AUDIT_PRIORITIES = ["P1", "P2", "P3"];
|
|
344
345
|
var AUDIT_EFFORTS = ["XS", "S", "M", "L", "XL"];
|
|
345
346
|
var AUDIT_RISKS = ["LOW", "MED", "HIGH"];
|
|
347
|
+
var AUDIT_CONFIDENCES = ["HIGH", "MED", "LOW"];
|
|
346
348
|
var AUDIT_CATEGORIES = [
|
|
347
349
|
"bug",
|
|
348
350
|
"security",
|
|
@@ -409,9 +411,11 @@ function validateAuditStatusBlocks(planText) {
|
|
|
409
411
|
return { ok: violations.length === 0, violations };
|
|
410
412
|
}
|
|
411
413
|
var WHOLE_MATCH_PATTERNS = [
|
|
412
|
-
{ type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
414
|
+
{ type: "private-key", re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----/g },
|
|
413
415
|
{ type: "aws-access-key", re: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
|
|
414
416
|
{ type: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{36,}\b/g },
|
|
417
|
+
{ type: "github-pat", re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/g },
|
|
418
|
+
{ type: "stripe-live-key", re: /\bsk_live_[A-Za-z0-9]{16,}\b/g },
|
|
415
419
|
{ type: "slack-token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
|
|
416
420
|
{ type: "jwt", re: /\beyJ[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\.[A-Za-z0-9_-]{10,1024}\b/g },
|
|
417
421
|
{ type: "api-secret-key", re: /\bsk-[A-Za-z0-9-]{20,}\b/g }
|
|
@@ -422,6 +426,36 @@ var VALUE_PATTERNS = [
|
|
|
422
426
|
re: /(["']?)\b(password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|secret|token)\b(["']?)(\s*[:=]\s*)("[^"\n]{8,}"|'[^'\n]{8,}'|[A-Za-z0-9_./+\-=]{16,})/gi
|
|
423
427
|
}
|
|
424
428
|
];
|
|
429
|
+
var NEVER_COMMIT_FILENAMES = [
|
|
430
|
+
{ type: "env-file", re: /^\.env/i },
|
|
431
|
+
{ type: "private-key-file", re: /\.(?:pem|key)$/i },
|
|
432
|
+
{ type: "ssh-private-key-file", re: /^id_(?:rsa|ed25519|ecdsa|dsa)$/ },
|
|
433
|
+
{ type: "credentials-json", re: /^credentials\.json$/i },
|
|
434
|
+
{ type: "service-account-json", re: /^service-account\.json$/i },
|
|
435
|
+
{ type: "git-credentials", re: /^\.?git-credentials$/i }
|
|
436
|
+
];
|
|
437
|
+
var CI_IAC_LEAK_SHAPES = [
|
|
438
|
+
{
|
|
439
|
+
kind: "actions-plaintext-env",
|
|
440
|
+
description: "GitHub Actions env assignment with plaintext literal",
|
|
441
|
+
re: /^\s*(?:-\s+)?env:\s*[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*\s*[:=]\s*["']?[A-Za-z0-9_/+=-]{8,}["']?\s*$/
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
kind: "actions-secret-echo",
|
|
445
|
+
description: "echo of a GitHub Actions secrets context value",
|
|
446
|
+
re: /\becho\b[^#\n]*\$\{\{\s*secrets\.[A-Za-z0-9_]+\s*\}\}/
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
kind: "dockerfile-credential-env",
|
|
450
|
+
description: "Dockerfile ENV/ARG with credential-looking name",
|
|
451
|
+
re: /^\s*(?:ENV|ARG)\s+[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|APIKEY|API_KEY|ACCESS_KEY|PRIVATE_KEY)[A-Z0-9_]*\b/i
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
kind: "terraform-hardcoded-password",
|
|
455
|
+
description: "Terraform hardcoded password attribute",
|
|
456
|
+
re: /^\s*password\s*=\s*"[^$\{][^"]*"\s*$/
|
|
457
|
+
}
|
|
458
|
+
];
|
|
425
459
|
function buildLineStarts(text) {
|
|
426
460
|
const starts = [0];
|
|
427
461
|
for (let i = 0;i < text.length; i++) {
|
|
@@ -443,17 +477,25 @@ function lineAt(starts, index) {
|
|
|
443
477
|
}
|
|
444
478
|
return lo + 1;
|
|
445
479
|
}
|
|
480
|
+
function lineStartOf(text, index) {
|
|
481
|
+
return text.lastIndexOf(`
|
|
482
|
+
`, index - 1) + 1;
|
|
483
|
+
}
|
|
446
484
|
function redactSecrets(text, filePath) {
|
|
447
485
|
const starts = buildLineStarts(text);
|
|
448
486
|
const marker = (type, index) => `[REDACTED ${type}@${lineAt(starts, index)}${filePath === undefined ? "" : ` in ${filePath}`}]`;
|
|
449
|
-
const
|
|
450
|
-
const findings = [];
|
|
487
|
+
const spans = [];
|
|
451
488
|
for (const pattern of WHOLE_MATCH_PATTERNS) {
|
|
452
489
|
for (const match of text.matchAll(pattern.re)) {
|
|
453
490
|
if (match.index === undefined)
|
|
454
491
|
continue;
|
|
455
|
-
|
|
456
|
-
|
|
492
|
+
spans.push({
|
|
493
|
+
start: match.index,
|
|
494
|
+
end: match.index + match[0].length,
|
|
495
|
+
priority: 0,
|
|
496
|
+
text: marker(pattern.type, match.index),
|
|
497
|
+
type: pattern.type
|
|
498
|
+
});
|
|
457
499
|
}
|
|
458
500
|
}
|
|
459
501
|
for (const pattern of VALUE_PATTERNS) {
|
|
@@ -462,20 +504,287 @@ function redactSecrets(text, filePath) {
|
|
|
462
504
|
continue;
|
|
463
505
|
const type = pattern.typeOf(match[2]);
|
|
464
506
|
const replacement = `${match[1]}${match[2]}${match[3]}${match[4]}${marker(type, match.index)}`;
|
|
465
|
-
|
|
466
|
-
findings.push({ line: lineAt(starts, match.index), type });
|
|
507
|
+
spans.push({ start: match.index, end: match.index + match[0].length, priority: 1, text: replacement, type });
|
|
467
508
|
}
|
|
468
509
|
}
|
|
469
|
-
|
|
510
|
+
for (const shape of CI_IAC_LEAK_SHAPES) {
|
|
511
|
+
const lineScoped = new RegExp(shape.re.source, shape.re.ignoreCase ? "gim" : "gm");
|
|
512
|
+
for (const match of text.matchAll(lineScoped)) {
|
|
513
|
+
if (match.index === undefined)
|
|
514
|
+
continue;
|
|
515
|
+
const lineEnd = text.indexOf(`
|
|
516
|
+
`, match.index);
|
|
517
|
+
const end = lineEnd === -1 ? text.length : lineEnd;
|
|
518
|
+
spans.push({
|
|
519
|
+
start: match.index,
|
|
520
|
+
end,
|
|
521
|
+
priority: 2,
|
|
522
|
+
text: `${" ".repeat(match.index - lineStartOf(text, match.index))}${marker(shape.kind, match.index)}`,
|
|
523
|
+
type: shape.kind
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end || b.priority - a.priority);
|
|
528
|
+
const merged = [];
|
|
529
|
+
let groupMaxEnd = -1;
|
|
530
|
+
let best = null;
|
|
531
|
+
for (const span of spans) {
|
|
532
|
+
if (span.start < groupMaxEnd) {
|
|
533
|
+
if (groupMaxEnd < span.end)
|
|
534
|
+
groupMaxEnd = span.end;
|
|
535
|
+
if (span.end - span.start > best.end - best.start)
|
|
536
|
+
best = span;
|
|
537
|
+
} else {
|
|
538
|
+
if (best !== null)
|
|
539
|
+
merged.push(best);
|
|
540
|
+
groupMaxEnd = span.end;
|
|
541
|
+
best = span;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
if (best !== null)
|
|
545
|
+
merged.push(best);
|
|
470
546
|
let out = text;
|
|
471
|
-
for (
|
|
472
|
-
|
|
547
|
+
for (let i = merged.length - 1;i >= 0; i--) {
|
|
548
|
+
const r = merged[i];
|
|
549
|
+
out = out.slice(0, r.start) + r.text + out.slice(r.end);
|
|
550
|
+
}
|
|
551
|
+
const findings = merged.map((r) => ({ line: lineAt(starts, r.start), type: r.type }));
|
|
473
552
|
const deduped = new Map;
|
|
474
553
|
for (const f of findings)
|
|
475
554
|
deduped.set(`${f.line}:${f.type}`, f);
|
|
476
555
|
const sorted = [...deduped.values()].sort((a, b) => a.line - b.line || a.type.localeCompare(b.type));
|
|
477
556
|
return { text: out, findings: sorted };
|
|
478
557
|
}
|
|
558
|
+
var SAFE_PLACEHOLDER_SHAPES = [
|
|
559
|
+
/\$\{[A-Za-z_][A-Za-z0-9_]*\}/g,
|
|
560
|
+
/process\.env\.[A-Za-z_][A-Za-z0-9_]*/g,
|
|
561
|
+
/os\.environ(?:\.get)?\(?\s*["']/g
|
|
562
|
+
];
|
|
563
|
+
var SAFE_PLACEHOLDER_VALUES = ["your-api-key-here", "<your_api_key>", "<your-api-key>"];
|
|
564
|
+
function maskSafePlaceholders(line) {
|
|
565
|
+
let masked = line;
|
|
566
|
+
for (const shape of [...SAFE_PLACEHOLDER_VALUES, ...SAFE_PLACEHOLDER_SHAPES]) {
|
|
567
|
+
if (typeof shape === "string") {
|
|
568
|
+
let at = masked.toLowerCase().indexOf(shape);
|
|
569
|
+
while (at !== -1) {
|
|
570
|
+
let from = at;
|
|
571
|
+
let to = at + shape.length;
|
|
572
|
+
if (masked[from - 1] === '"' || masked[from - 1] === "'")
|
|
573
|
+
from--;
|
|
574
|
+
if (masked[to] === '"' || masked[to] === "'")
|
|
575
|
+
to++;
|
|
576
|
+
masked = masked.slice(0, from) + " ".repeat(to - from) + masked.slice(to);
|
|
577
|
+
at = masked.toLowerCase().indexOf(shape);
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
for (const match of masked.matchAll(shape)) {
|
|
581
|
+
if (match.index === undefined)
|
|
582
|
+
continue;
|
|
583
|
+
let from = match.index;
|
|
584
|
+
let to = match.index + match[0].length;
|
|
585
|
+
if (masked[from - 1] === '"' || masked[from - 1] === "'")
|
|
586
|
+
from--;
|
|
587
|
+
if (masked[to] === '"' || masked[to] === "'")
|
|
588
|
+
to++;
|
|
589
|
+
masked = masked.slice(0, from) + " ".repeat(to - from) + masked.slice(to);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return masked;
|
|
594
|
+
}
|
|
595
|
+
var ACTIONS_ENV_KEY = /[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|KEY)[A-Z0-9_]*/;
|
|
596
|
+
function scanActionsEnvMap(lines) {
|
|
597
|
+
const out = [];
|
|
598
|
+
let inMap = false;
|
|
599
|
+
let mapIndent = 0;
|
|
600
|
+
for (let i = 0;i < lines.length; i++) {
|
|
601
|
+
const rawLine = lines[i] ?? "";
|
|
602
|
+
if (!rawLine.trim())
|
|
603
|
+
continue;
|
|
604
|
+
const indent = rawLine.length - rawLine.trimStart().length;
|
|
605
|
+
const line = maskSafePlaceholders(rawLine);
|
|
606
|
+
if (inMap) {
|
|
607
|
+
if (indent <= mapIndent) {
|
|
608
|
+
inMap = false;
|
|
609
|
+
} else if (line.trim() !== "") {
|
|
610
|
+
const child = /^\s*(?:["']?)([A-Za-z0-9_-]+)(?:["']?)\s*:\s*(.+?)\s*$/.exec(line);
|
|
611
|
+
const value = child?.[2] ?? "";
|
|
612
|
+
if (child !== null && ACTIONS_ENV_KEY.test(child[1]) && !/^\$\{\{[^}]*\}\}$/.test(value) && !/^\$\{[^}]*\}$/.test(value)) {
|
|
613
|
+
out.push(i + 1);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
if (inMap)
|
|
617
|
+
continue;
|
|
618
|
+
}
|
|
619
|
+
if (/^\s*(?:-\s+)?env:\s*(?:#.*)?$/.test(line)) {
|
|
620
|
+
inMap = true;
|
|
621
|
+
mapIndent = indent;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return out;
|
|
625
|
+
}
|
|
626
|
+
function scanSecrets(files) {
|
|
627
|
+
const findings = [];
|
|
628
|
+
let unreadableFiles = 0;
|
|
629
|
+
const privateKeyRow = WHOLE_MATCH_PATTERNS.find((pattern) => pattern.type === "private-key");
|
|
630
|
+
if (privateKeyRow === undefined) {
|
|
631
|
+
throw new Error("scanSecrets: WHOLE_MATCH_PATTERNS is missing its private-key row (full-text PEM pass)");
|
|
632
|
+
}
|
|
633
|
+
for (const file of files) {
|
|
634
|
+
let text;
|
|
635
|
+
try {
|
|
636
|
+
text = readFileSync3(file, "utf8");
|
|
637
|
+
} catch {
|
|
638
|
+
unreadableFiles++;
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
const base = basename2(file);
|
|
642
|
+
for (const entry of NEVER_COMMIT_FILENAMES) {
|
|
643
|
+
if (entry.re.test(base))
|
|
644
|
+
findings.push({ file, line: 1, type: entry.type });
|
|
645
|
+
}
|
|
646
|
+
const starts = buildLineStarts(text);
|
|
647
|
+
for (const match of text.matchAll(privateKeyRow.re)) {
|
|
648
|
+
if (match.index === undefined)
|
|
649
|
+
continue;
|
|
650
|
+
findings.push({ file, line: lineAt(starts, match.index), type: privateKeyRow.type });
|
|
651
|
+
}
|
|
652
|
+
const lines = text.split(`
|
|
653
|
+
`);
|
|
654
|
+
for (const lineNo of scanActionsEnvMap(lines)) {
|
|
655
|
+
findings.push({ file, line: lineNo, type: "actions-plaintext-env" });
|
|
656
|
+
}
|
|
657
|
+
for (let i = 0;i < lines.length; i++) {
|
|
658
|
+
const line = maskSafePlaceholders(lines[i] ?? "");
|
|
659
|
+
for (const pattern of WHOLE_MATCH_PATTERNS) {
|
|
660
|
+
if (line.match(pattern.re) !== null)
|
|
661
|
+
findings.push({ file, line: i + 1, type: pattern.type });
|
|
662
|
+
}
|
|
663
|
+
for (const pattern of VALUE_PATTERNS) {
|
|
664
|
+
const match = pattern.re.exec(line);
|
|
665
|
+
pattern.re.lastIndex = 0;
|
|
666
|
+
if (match !== null && !/^\$\{[^}]*\}$/.test(match[5])) {
|
|
667
|
+
findings.push({ file, line: i + 1, type: pattern.typeOf(match[2]) });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
for (const shape of CI_IAC_LEAK_SHAPES) {
|
|
671
|
+
if (shape.re.test(line))
|
|
672
|
+
findings.push({ file, line: i + 1, type: shape.kind });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return { findings, unreadableFiles };
|
|
677
|
+
}
|
|
678
|
+
var LOCKFILE_NAMES = [
|
|
679
|
+
"package-lock.json",
|
|
680
|
+
"pnpm-lock.yaml",
|
|
681
|
+
"yarn.lock",
|
|
682
|
+
"bun.lock",
|
|
683
|
+
"bun.lockb",
|
|
684
|
+
"Cargo.lock",
|
|
685
|
+
"poetry.lock",
|
|
686
|
+
"uv.lock",
|
|
687
|
+
"Gemfile.lock",
|
|
688
|
+
"composer.lock"
|
|
689
|
+
];
|
|
690
|
+
function rootLockfiles(root) {
|
|
691
|
+
let entries;
|
|
692
|
+
try {
|
|
693
|
+
entries = readdirSync2(root, { withFileTypes: true });
|
|
694
|
+
} catch {
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
const names = new Set(LOCKFILE_NAMES);
|
|
698
|
+
const present = entries.filter((entry) => entry.isFile() && names.has(entry.name)).map((entry) => join4(root, entry.name));
|
|
699
|
+
if (present.length === 0)
|
|
700
|
+
return [];
|
|
701
|
+
try {
|
|
702
|
+
const tracked = new Set(execFileSync("git", ["ls-files", "-z", "--", "."], {
|
|
703
|
+
cwd: root,
|
|
704
|
+
encoding: "utf8",
|
|
705
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
706
|
+
}).split("\x00").filter((f) => f !== ""));
|
|
707
|
+
return present.filter((p) => tracked.has(basename2(p)));
|
|
708
|
+
} catch {
|
|
709
|
+
return present;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
function supplyChainChecks(repoRoot) {
|
|
713
|
+
const findings = [];
|
|
714
|
+
const violations = [];
|
|
715
|
+
const lockfiles = rootLockfiles(repoRoot);
|
|
716
|
+
if (lockfiles.length === 0) {
|
|
717
|
+
findings.push({ kind: "lockfile-missing", file: repoRoot });
|
|
718
|
+
violations.push(violation2("medium", "audit.supply.lockfile-missing", `no recognized lockfile at ${repoRoot}`, "commit a lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock, …)"));
|
|
719
|
+
} else if (lockfiles.length > 1) {
|
|
720
|
+
findings.push({ kind: "lockfile-duplicate", file: lockfiles.map((f) => f.replace(`${repoRoot}/`, "")).join(", ") });
|
|
721
|
+
violations.push(violation2("medium", "audit.supply.lockfile-duplicate", `multiple lockfiles at ${repoRoot}: ${lockfiles.join(", ")}`, "keep exactly one lockfile per package manager"));
|
|
722
|
+
}
|
|
723
|
+
const workflowsDir = join4(repoRoot, ".github", "workflows");
|
|
724
|
+
let wfEntries = [];
|
|
725
|
+
try {
|
|
726
|
+
wfEntries = readdirSync2(workflowsDir, { withFileTypes: true });
|
|
727
|
+
} catch {
|
|
728
|
+
wfEntries = [];
|
|
729
|
+
}
|
|
730
|
+
for (const entry of wfEntries) {
|
|
731
|
+
if (!entry.isFile() || !/\.(?:ya?ml)$/.test(entry.name))
|
|
732
|
+
continue;
|
|
733
|
+
const wfPath = join4(workflowsDir, entry.name);
|
|
734
|
+
const relPath = `.github/workflows/${entry.name}`;
|
|
735
|
+
let text;
|
|
736
|
+
try {
|
|
737
|
+
text = readFileSync3(wfPath, "utf8");
|
|
738
|
+
} catch {
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
const lines = text.split(`
|
|
742
|
+
`);
|
|
743
|
+
const hasPrt = /(?:^|\n)\s*(?:(?:-\s+)?pull_request_target\b|on:\s*(?:\[[^\]]*\s*)?pull_request_target\b)/.test(text);
|
|
744
|
+
const prtHeadSteps = new Set;
|
|
745
|
+
for (let i = 0;i < lines.length; i++) {
|
|
746
|
+
const line = lines[i] ?? "";
|
|
747
|
+
if (!/uses:\s*actions\/checkout\b/.test(line))
|
|
748
|
+
continue;
|
|
749
|
+
let stepIndent = line.length - line.trimStart().length;
|
|
750
|
+
for (let k = i - 1;k >= 0; k--) {
|
|
751
|
+
const up = lines[k] ?? "";
|
|
752
|
+
const ind = up.length - up.trimStart().length;
|
|
753
|
+
if (up.trimStart().startsWith("- ") && ind < stepIndent) {
|
|
754
|
+
stepIndent = ind;
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
for (let j = i + 1;j < lines.length; j++) {
|
|
759
|
+
const l2 = lines[j] ?? "";
|
|
760
|
+
if (l2.trim() && l2.length - l2.trimStart().length <= stepIndent)
|
|
761
|
+
break;
|
|
762
|
+
if (/github\.event\.pull_request\.head\.(?:sha|ref)\b/.test(l2)) {
|
|
763
|
+
prtHeadSteps.add(i);
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
for (let i = 0;i < lines.length; i++) {
|
|
769
|
+
const line = lines[i] ?? "";
|
|
770
|
+
const uses = /^\s*(?:-\s+)?uses:\s*(\S+)@(\S+)\s*(?:#.*)?$/.exec(line);
|
|
771
|
+
if (uses !== null) {
|
|
772
|
+
const ref = uses[2].replace(/^["']|["']$/g, "");
|
|
773
|
+
const shaLike = /^[0-9a-f]{40}$/.test(ref);
|
|
774
|
+
const versionLike = /^v\d+(?:\.\d+)*$/.test(ref);
|
|
775
|
+
if (!shaLike && !versionLike) {
|
|
776
|
+
findings.push({ kind: "action-unpinned", file: relPath, line: i + 1 });
|
|
777
|
+
violations.push(violation2("high", "audit.supply.action-unpinned", `${relPath}:${i + 1} uses \`${uses[1]}@${ref}\` — mutable ref`, "pin the action to a full commit SHA"));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (hasPrt && prtHeadSteps.has(i)) {
|
|
781
|
+
findings.push({ kind: "pull_request_target-head", file: relPath, line: i + 1 });
|
|
782
|
+
violations.push(violation2("high", "audit.supply.pull_request_target-head", `${relPath}:${i + 1} checks out the PR head under pull_request_target`, "check out the base ref or use a pull_request trigger for untrusted code"));
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return { ok: violations.length === 0, violations, findings };
|
|
787
|
+
}
|
|
479
788
|
function slugify(title) {
|
|
480
789
|
return title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
481
790
|
}
|
|
@@ -509,6 +818,19 @@ function renderPlanFile(finding, plannedAt) {
|
|
|
509
818
|
`)}
|
|
510
819
|
`;
|
|
511
820
|
}
|
|
821
|
+
function redactText(text) {
|
|
822
|
+
return redactSecrets(text).text;
|
|
823
|
+
}
|
|
824
|
+
function redactFinding(finding) {
|
|
825
|
+
return {
|
|
826
|
+
...finding,
|
|
827
|
+
title: redactText(finding.title),
|
|
828
|
+
impact: redactText(finding.impact),
|
|
829
|
+
evidence: finding.evidence.map(redactText),
|
|
830
|
+
...finding.fixSketch !== undefined ? { fixSketch: redactText(finding.fixSketch) } : {},
|
|
831
|
+
...finding.verification !== undefined ? { verification: redactText(finding.verification) } : {}
|
|
832
|
+
};
|
|
833
|
+
}
|
|
512
834
|
function readPlanFileSummary(filePath) {
|
|
513
835
|
const text = readFileSync3(filePath, "utf8");
|
|
514
836
|
const title = (text.match(/^# (.+)$/m) ?? [])[1] ?? filePath;
|
|
@@ -569,9 +891,10 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
569
891
|
const carried = existsSync3(existingReadme) ? extractSecurityDispositionSections(readFileSync3(existingReadme, "utf8")) : { needsVerification: [], hardeningChecked: [] };
|
|
570
892
|
const existing = readdirSync2(outDir).filter((f) => /^\d{3}-.*\.md$/.test(f));
|
|
571
893
|
let next = existing.reduce((max, f) => Math.max(max, Number(f.slice(0, 3))), 0) + 1;
|
|
894
|
+
const redactedFindings = findings.map(redactFinding);
|
|
572
895
|
const written = [];
|
|
573
896
|
const usedSlugs = new Set;
|
|
574
|
-
for (const finding of
|
|
897
|
+
for (const finding of redactedFindings) {
|
|
575
898
|
const num = String(next).padStart(3, "0");
|
|
576
899
|
let slug = slugify(finding.title);
|
|
577
900
|
if (usedSlugs.has(slug)) {
|
|
@@ -605,7 +928,7 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
605
928
|
});
|
|
606
929
|
const byNum = new Map(rows.map((r) => [r.num, r]));
|
|
607
930
|
written.forEach((file, i) => {
|
|
608
|
-
const finding =
|
|
931
|
+
const finding = redactedFindings[i];
|
|
609
932
|
if (finding === undefined)
|
|
610
933
|
return;
|
|
611
934
|
const row = byNum.get(file.slice(0, 3));
|
|
@@ -620,14 +943,14 @@ function scaffoldAuditPlan(outDir, findings, options = {}) {
|
|
|
620
943
|
row.dependsOn = finding.dependsOn ?? "none";
|
|
621
944
|
}
|
|
622
945
|
});
|
|
623
|
-
const needsVerificationLines = options.needsVerification !== undefined ? options.needsVerification.map((nv) => `- ${escapeCell(nv.lead)}: ${escapeCell(nv.how)}${nv.evidence ? ` (${escapeCell(nv.evidence)})` : ""}`) : carried.needsVerification;
|
|
624
|
-
const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(hc.text)}`) : carried.hardeningChecked;
|
|
946
|
+
const needsVerificationLines = options.needsVerification !== undefined ? options.needsVerification.map((nv) => `- ${escapeCell(redactText(nv.lead))}: ${escapeCell(redactText(nv.how))}${nv.evidence ? ` (${escapeCell(redactText(nv.evidence))})` : ""}`) : carried.needsVerification;
|
|
947
|
+
const hardeningCheckedLines = options.hardeningChecked !== undefined ? options.hardeningChecked.map((hc) => `- ${hc.kind}: ${escapeCell(redactText(hc.text))}`) : carried.hardeningChecked;
|
|
625
948
|
writeFileSync3(join4(outDir, "README.md"), renderIndex({
|
|
626
949
|
date,
|
|
627
950
|
repoName: options.repoName ?? "repo",
|
|
628
951
|
repoShortSha: options.repoShortSha ?? "unknown",
|
|
629
952
|
rows,
|
|
630
|
-
rejected: options.rejected ?? [],
|
|
953
|
+
rejected: (options.rejected ?? []).map((r) => ({ title: redactText(r.title), reason: redactText(r.reason) })),
|
|
631
954
|
needsVerification: needsVerificationLines,
|
|
632
955
|
hardeningChecked: hardeningCheckedLines
|
|
633
956
|
}));
|
|
@@ -766,11 +1089,18 @@ function planFileRel(outDir, planFile) {
|
|
|
766
1089
|
}
|
|
767
1090
|
export {
|
|
768
1091
|
validateAuditStatusBlocks,
|
|
1092
|
+
supplyChainChecks,
|
|
1093
|
+
scanSecrets,
|
|
769
1094
|
scaffoldAuditPlan,
|
|
770
1095
|
redactSecrets,
|
|
771
1096
|
promoteAuditPlans,
|
|
1097
|
+
WHOLE_MATCH_PATTERNS,
|
|
1098
|
+
VALUE_PATTERNS,
|
|
1099
|
+
NEVER_COMMIT_FILENAMES,
|
|
1100
|
+
CI_IAC_LEAK_SHAPES,
|
|
772
1101
|
AUDIT_RISKS,
|
|
773
1102
|
AUDIT_PRIORITIES,
|
|
774
1103
|
AUDIT_EFFORTS,
|
|
1104
|
+
AUDIT_CONFIDENCES,
|
|
775
1105
|
AUDIT_CATEGORIES
|
|
776
1106
|
};
|
package/dist/dispatch.d.ts
CHANGED
|
@@ -172,13 +172,24 @@ export declare function assertTriIdentity(reviewerRoles: readonly string[]): Gat
|
|
|
172
172
|
*/
|
|
173
173
|
export type ComposeDispatchGateOptions = {
|
|
174
174
|
/**
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
175
|
+
* Dispatching agent's OWN harness role (dsh `Config.dispatchBinding`).
|
|
176
|
+
* When non-empty, the anti-recursion precheck compares it against the
|
|
177
|
+
* Assignment's `Execute as` — equality is self-recursion
|
|
178
|
+
* (`dispatch.anti-recursion.self-type`, critical). Leave unset on hosts
|
|
179
|
+
* whose tool-call event cannot report the dispatching agent's identity
|
|
180
|
+
* (omp/opencode/cursor): the precheck is skipped there and the NEVER red
|
|
181
|
+
* line stays prompt-level (mstar-dispatch-gates).
|
|
180
182
|
*/
|
|
181
|
-
|
|
183
|
+
caller?: string;
|
|
184
|
+
/**
|
|
185
|
+
* True on hosts whose contract declares the caller binding mandatory
|
|
186
|
+
* (dsh): an empty/missing `caller` then fails closed with
|
|
187
|
+
* `dispatch.anti-recursion.empty-binding` (critical) — the host could
|
|
188
|
+
* have declared the binding, so an absent one proves nothing and the
|
|
189
|
+
* dispatch must not proceed as if the NEVER red line held. Default
|
|
190
|
+
* `false`: the precheck is skipped entirely when `caller` is empty.
|
|
191
|
+
*/
|
|
192
|
+
callerRequired?: boolean;
|
|
182
193
|
/**
|
|
183
194
|
* `false` for read-only roles (scout/explore) — skips the branch-form and
|
|
184
195
|
* default-branch gates. Default: `true` (writable).
|
|
@@ -206,10 +217,14 @@ export type ComposeDispatchGateResult = GateResult & {
|
|
|
206
217
|
* Assignment-shaped passes silently (`shaped: false`).
|
|
207
218
|
* 2. `validateAssignmentFields` with `writable: false` when `opts.writable
|
|
208
219
|
* === false` (read-only roles), else the writable default.
|
|
209
|
-
* 3. Anti-recursion precheck —
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* `
|
|
220
|
+
* 3. Anti-recursion precheck — CALLER semantics (issue #156): runs only
|
|
221
|
+
* when the host supplies its own role binding (`caller`), or fails
|
|
222
|
+
* closed on an empty one when the host contract requires it
|
|
223
|
+
* (`callerRequired`, dsh). A caller equal to `Execute as` is the
|
|
224
|
+
* `dispatch.anti-recursion.self-type` critical; a required-but-empty
|
|
225
|
+
* caller is `dispatch.anti-recursion.empty-binding`. Target-only hosts
|
|
226
|
+
* (omp/opencode/cursor) skip the leg — their binding field carries the
|
|
227
|
+
* spawn TARGET, which equals `Execute as` on every compliant dispatch.
|
|
213
228
|
* 4. Default-branch gate for writable text: the branch comes from the
|
|
214
229
|
* Assignment's own branch forms (create-form name / Working branch /
|
|
215
230
|
* Branch policy branch), else `$MSTAR_WORKING_BRANCH`; a well-formed
|
|
@@ -224,13 +239,17 @@ export type ComposeDispatchGateResult = GateResult & {
|
|
|
224
239
|
export declare function composeDispatchGate(text: string, opts?: ComposeDispatchGateOptions): ComposeDispatchGateResult;
|
|
225
240
|
/**
|
|
226
241
|
* Anti-recursion precheck (NEVER red line, mstar-dispatch-gates § 承接方反递归
|
|
227
|
-
* 红线): a leaf executor MUST NOT
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
* dispatch
|
|
233
|
-
*
|
|
234
|
-
*
|
|
242
|
+
* 红线): a leaf executor MUST NOT dispatch a Task/subagent whose target role
|
|
243
|
+
* (the new Assignment's `Execute as`) equals its OWN role. `subagentType`
|
|
244
|
+
* is therefore the DISPATCHING agent's own role binding (dsh
|
|
245
|
+
* `Config.dispatchBinding`) — never the spawn-target field (omp `agent` /
|
|
246
|
+
* opencode `subagent`), which equals `Execute as` on every compliant
|
|
247
|
+
* dispatch (issue #156). Comparison is case-insensitive after trim. An
|
|
248
|
+
* EMPTY binding fails closed (`dispatch.anti-recursion.empty-binding`,
|
|
249
|
+
* critical): the host cannot report which agent is calling, so
|
|
250
|
+
* anti-recursion cannot be proven — the dispatch must not proceed as if
|
|
251
|
+
* the NEVER red line held. An empty `executeAs` with a set binding stays
|
|
252
|
+
* ok (field presence is `validateAssignmentFields`' job, not this
|
|
253
|
+
* precheck).
|
|
235
254
|
*/
|
|
236
255
|
export declare function antiRecursionPrecheck(subagentType: string, executeAs: string): GateResult;
|