@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 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: "HIGH" | "MED" | "LOW";
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 replacements = [];
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
- replacements.push({ index: match.index, length: match[0].length, text: marker(pattern.type, match.index) });
456
- findings.push({ line: lineAt(starts, match.index), type: pattern.type });
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
- replacements.push({ index: match.index, length: match[0].length, text: replacement });
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
- replacements.sort((a, b) => b.index - a.index);
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 (const r of replacements)
472
- out = out.slice(0, r.index) + r.text + out.slice(r.index + r.length);
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 findings) {
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 = findings[i];
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
  };
@@ -172,13 +172,24 @@ export declare function assertTriIdentity(reviewerRoles: readonly string[]): Gat
172
172
  */
173
173
  export type ComposeDispatchGateOptions = {
174
174
  /**
175
- * Host role-binding field (omp task entry `agent` / opencode `subagent` /
176
- * cursor `subagent_type` / dsh `dispatchBinding`). The anti-recursion
177
- * precheck runs for EVERY Assignment-shaped text: an empty or omitted
178
- * binding fails closed (`dispatch.anti-recursion.empty-binding`) the
179
- * host cannot prove the dispatching agent is not recursing.
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
- agent?: string;
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 — runs for every Assignment-shaped text; an
210
- * empty/omitted host binding fails closed (`dispatch.anti-recursion.
211
- * empty-binding`), a binding equal to `Execute as` is the existing
212
- * `dispatch.anti-recursion.self-type` critical.
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 invoke a Task/subagent whose role-binding
228
- * field (`subagent_type` / `agent` / `subagent`) equals its own `Execute as`.
229
- * Comparison is case-insensitive after trim. An EMPTY binding fails closed
230
- * (`dispatch.anti-recursion.empty-binding`, critical): the host cannot
231
- * report which agent is calling, so anti-recursion cannot be proven the
232
- * dispatch must not proceed as if the NEVER red line held. An empty
233
- * `executeAs` with a set binding stays ok (field presence is
234
- * `validateAssignmentFields`' job, not this precheck).
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;