@clear-capabilities/agentic-security-scanner 0.159.0 → 0.161.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/CHANGELOG.md +28 -0
- package/dist/1919.index.js +1 -1
- package/dist/2432.index.js +10 -1
- package/dist/4113.index.js +1 -1
- package/dist/7033.index.js +4 -1
- package/dist/7838.index.js +1 -1
- package/dist/8714.index.js +1 -1
- package/dist/8798.index.js +1 -1
- package/dist/9560.index.js +170 -41
- package/dist/agentic-security.mjs +12 -12
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +12 -9
- package/src/dataflow/engine.js +20 -4
- package/src/engine.js +29 -8
- package/src/language/CLAUDE.md +6 -1
- package/src/language/aibom.js +4 -1
- package/src/language/fix-lifecycle.js +9 -0
- package/src/language/haskell-ir.js +47 -7
- package/src/language/haskell-sca.js +87 -7
- package/src/language/haskell-supply.js +175 -6
- package/src/language/haskell-web.js +200 -18
- package/src/language/nix-fix.js +1 -1
- package/src/language/resolved-pass.js +4 -1
- package/src/language/witness.js +1 -1
- package/src/mcp/CLAUDE.md +34 -3
- package/src/mcp/tools.js +168 -39
- package/src/posture/blast-radius.js +15 -1
- package/src/posture/remediation-ledger.js +10 -1
- package/src/sast/pipeline.js +51 -4
package/src/mcp/CLAUDE.md
CHANGED
|
@@ -13,7 +13,7 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
13
13
|
| `lookup_cve` | ✓ | reads local OSV / KEV / EPSS cache; staleness-tiered |
|
|
14
14
|
| `synthesize_fix` | ✓ | reads last-scan; returns the patch text |
|
|
15
15
|
| `verify_fix` | ✗ | re-scans patched files in memory, runs lint + the project test suite + the fix-honesty gate + PoC re-check; does not touch the target project's own files, but appends a record to `.agentic-security/fix-metrics.jsonl` per attempt |
|
|
16
|
-
| `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup) |
|
|
16
|
+
| `apply_fix` | ✗ | writes via `posture/fix-history.js` (with backup). With `plan_digest` it applies a MULTI-FILE NixOS option fix (see "Multi-file apply_fix" below): all files or none, one backup per file, one history group |
|
|
17
17
|
| `append_scratchpad` | ✗ | writes under `.agentic-security/agent-scratchpad/<agent>/<session>/` only |
|
|
18
18
|
| `read_scratchpad` | ✓ | paginated read of scratchpad files |
|
|
19
19
|
| `append_agents_memory` | ✗ | appends to `.agentic-security/AGENTS.md` continual-learning file |
|
|
@@ -42,6 +42,8 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
42
42
|
| Path-escape refusal | `tools.js::_confine` lexical check before any fs call |
|
|
43
43
|
| Reserved-write paths | `tools.js::RESERVED_WRITE_*` — `.git/`, `.github/`, `.gitlab/`, `.circleci/`, `.buildkite/`, `.agentic-security/`, `node_modules/`, `.terraform/`, `.aws/`, `k8s/`, manifest basenames, `*.tf`, `docker-compose.yml` |
|
|
44
44
|
| HMAC integrity on findings | `posture/integrity.js` — per-install random key at `$XDG_CONFIG_HOME/agentic-security/scan-key`. **Not** hostname-derived. |
|
|
45
|
+
| Multi-file plan binding | `tools.js::_multiFilePlanDigest` / `_applyMultiFilePlan`: `apply_fix` never accepts file content for a multi-file plan; it recomputes the plan from the signed finding + live tree and compares a digest (finding id, stableId, every path, SHA-256 of every pre/post image) with the caller's `plan_digest`. The lifecycle's `preWrite` hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written |
|
|
46
|
+
| Multi-file confinement | `tools.js::_checkMultiFileTargets`: EVERY file of the plan gets clean root-relative form (no absolute, `..`, `.`, backslash, NUL, empty segment), `_confine` (no symlink leaf, no escape), a no-symlinked-parent check (realpath must equal the lexical path), the reserved-write list, existence, no duplicates, at most 8 files. One bad file refuses the whole plan |
|
|
45
47
|
| Patches pass through unredacted | `tools.js` synthesize_fix / apply_fix — premortem-derived. Patches are not findings; redacting them silently corrupts valid fixes. |
|
|
46
48
|
| Secret redaction on findings | `redact.js` — applied to snippet/description/title/vuln/remediation/trace |
|
|
47
49
|
| Audit log | `audit.js` — NDJSON, hash-chained, at `.agentic-security/mcp-audit.log`. Set `$AGENTIC_SECURITY_AUDIT_WEBHOOK=<url>` to also fire-and-forget POST every entry to a remote witness — closes the full-file-rewrite blind spot. Failures land in `mcp-audit.remote-errors.log` and never block a tool call. |
|
|
@@ -70,5 +72,34 @@ MCP server. JSON-RPC 2.0 over NDJSON on stdin/stdout. Bin entry `../../bin/agent
|
|
|
70
72
|
|
|
71
73
|
`scan_diff`, `verify_fix` and `synthesize_fix` add the import closure of a Haskell or Nix file and the manifests as context
|
|
72
74
|
(`language/context.js` `withLanguageContext`, bounded in files and bytes, read-only) and still report only the files asked
|
|
73
|
-
about. The tool that previews a fix for these files uses the language fixers
|
|
74
|
-
path)
|
|
75
|
+
about. The tool that previews a fix for these files uses the language fixers. A single-file `apply_fix` is unchanged (its generic
|
|
76
|
+
confined-write path); the language-aware apply with backup and `undo` for everything else is the CLI's `fix --apply`.
|
|
77
|
+
|
|
78
|
+
## Multi-file `apply_fix` (NixOS option fixes, `plan_digest`)
|
|
79
|
+
|
|
80
|
+
A NixOS option is judged on the effective configuration, so one fix can edit several files (every definition at the winning
|
|
81
|
+
priority). Flow: `synthesize_fix` returns `languageFix.multiFile` (the files), `languageFix.edits` (per file: path + SHA-256 of
|
|
82
|
+
the before and after image, **no content**) and `languageFix.planDigest` after running the verification gates read-only. The
|
|
83
|
+
caller then sends `apply_fix {finding_id, confirm:true, plan_digest}` (optionally `dry_run:true`, `fixMeta.approval` for a
|
|
84
|
+
high-impact change).
|
|
85
|
+
|
|
86
|
+
Design decision: the caller **cannot supply file content** on this path (`plan_digest` plus `patch` is refused). The server
|
|
87
|
+
re-derives the plan with `planNixFix` over `loadLanguageProject` and refuses unless the digest matches, so "the plan applied is
|
|
88
|
+
exactly the plan previewed" holds by construction, and a changed project file, a changed finding or an altered digest all give
|
|
89
|
+
`stale:true` with no hint of the expected value. Order of checks: `confirm:true` -> last-scan HMAC -> finding exists -> not shadow
|
|
90
|
+
-> digest format -> Nix finding with stableId -> plan exists and has 2+ files -> digest equal -> every file confined and not
|
|
91
|
+
reserved -> high-impact approval (shared `_highImpactApprovalRefusal`, same code as the `patch` branch) -> lifecycle
|
|
92
|
+
(`validateNixFix`, apply): syntax, rescan (original gone, nothing new at medium+), effective-value gate, then `preWrite`, then
|
|
93
|
+
`writeManyWithBackup` (checks every file unchanged since planning and backs every file up before the first write). History is
|
|
94
|
+
one `languageGroupId` group: `undo` / `undoLast` revert all files or none.
|
|
95
|
+
|
|
96
|
+
Results are non-leaky: absolute paths are scrubbed to `<root>` and passed through `redactString`. A failed write is reported as
|
|
97
|
+
`applied:false` with `rolledBack` and `rollbackIncomplete` (+ `restoreManually` file names) honestly; an incomplete rollback is
|
|
98
|
+
never reported as success.
|
|
99
|
+
|
|
100
|
+
What this deliberately does NOT do: no caller-supplied multi-file `patch` (would reopen the plan-binding problem), no Haskell
|
|
101
|
+
multi-file plans (the Haskell planner produces none), no `flake.lock` relock plans (`.lock` is on the reserved-write list and
|
|
102
|
+
the relock path is not a finding fix), no fix-attempt-budget enforcement (the language lifecycle records attempts but, as in the
|
|
103
|
+
CLI, does not enforce a cap), and no `fixMeta` honesty gate (that gate judges a caller's residual/verdict claims, which this
|
|
104
|
+
path does not take). The `preWrite` digest re-check is defence in depth over a deterministic planner and has no independent
|
|
105
|
+
test that fails when it is removed (the earlier digest comparison is the pinned control).
|
package/src/mcp/tools.js
CHANGED
|
@@ -349,7 +349,7 @@ function _maybeOffload(sessionRoot, toolName, items) {
|
|
|
349
349
|
//
|
|
350
350
|
// Exported under the `_internals` convention this codebase already uses
|
|
351
351
|
// (see posture/poc-inprocess.js). Not part of the MCP tool surface.
|
|
352
|
-
export const _internals = { _confine, isReservedWrite: _isReservedWritePath };
|
|
352
|
+
export const _internals = { _confine, isReservedWrite: _isReservedWritePath, checkMultiFileTargets: _checkMultiFileTargets, multiFilePlanDigest: _multiFilePlanDigest };
|
|
353
353
|
|
|
354
354
|
export const scan_diff = {
|
|
355
355
|
name: 'scan_diff',
|
|
@@ -603,10 +603,153 @@ export const explain_finding = {
|
|
|
603
603
|
},
|
|
604
604
|
};
|
|
605
605
|
|
|
606
|
+
// FR-307/FR-1002/FR-1003: the high-impact-change approval gate (approval evidence, approver identity, separation of duties),
|
|
607
|
+
// shared by the caller-patch branch and the multi-file plan branch so the two cannot drift. Returns a refusal result, or null
|
|
608
|
+
// when the change is not high-impact or is properly approved.
|
|
609
|
+
function _highImpactApprovalRefusal(ctx, materialClassification, fixMeta) {
|
|
610
|
+
if (!materialClassification.highImpactCategories.length) return null;
|
|
611
|
+
const cats = materialClassification.highImpactCategories.join(', ');
|
|
612
|
+
const approval = fixMeta && typeof fixMeta === 'object' ? fixMeta.approval : null;
|
|
613
|
+
const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
|
|
614
|
+
typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
|
|
615
|
+
typeof approval.reason === 'string' && approval.reason.trim().length > 0);
|
|
616
|
+
if (!hasApprovalEvidence) {
|
|
617
|
+
return {
|
|
618
|
+
_meta: META, applied: false,
|
|
619
|
+
reason: `high-impact change (${cats}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
|
|
620
|
+
materialClassification,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
|
|
624
|
+
const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
|
|
625
|
+
const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
|
|
626
|
+
if (!identityCheck.verified) {
|
|
627
|
+
return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${identityCheck.reason}`, materialClassification };
|
|
628
|
+
}
|
|
629
|
+
// FR-1003: separation-of-duties, same no-op-unless-configured gate as apply-fix-service.js's own copy; see approver-registry.js.
|
|
630
|
+
const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
|
|
631
|
+
if (!sodCheck.ok) {
|
|
632
|
+
return { _meta: META, applied: false, reason: `high-impact change (${cats}) approval rejected: ${sodCheck.reason}`, materialClassification };
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// ─── Multi-file plans (apply_fix with plan_digest) ───────────────────────────
|
|
638
|
+
// A NixOS option fix can edit several files at once (every definition at the winning priority). apply_fix never accepts file
|
|
639
|
+
// contents for this: it RECOMPUTES the plan from the signed finding and the live tree, and writes only if that plan's digest is
|
|
640
|
+
// the one synthesize_fix returned. The digest binds the finding, every file path, the exact pre-image and the exact post-image
|
|
641
|
+
// of each edit, so a changed tree, a changed finding, or an altered digest all refuse. The write itself is the language
|
|
642
|
+
// lifecycle's all-or-nothing writeManyWithBackup (one backup per file, one history group, honest rollback).
|
|
643
|
+
const MULTI_FILE_MAX = 8;
|
|
644
|
+
const _sha256 = (t) => crypto.createHash('sha256').update(String(t), 'utf8').digest('hex');
|
|
645
|
+
function _multiFilePlanDigest(finding, edits) {
|
|
646
|
+
const canon = edits.map((e) => ({ file: String(e.file), before: _sha256(e.before), after: _sha256(e.after) })).sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
647
|
+
return _sha256(JSON.stringify({ v: 'mf-plan/1', id: finding.id || null, stableId: finding.stableId || null, edits: canon }));
|
|
648
|
+
}
|
|
649
|
+
// Every file of a multi-file plan gets the SAME checks a single-file apply_fix target gets: root-relative lexical form,
|
|
650
|
+
// confinement (no symlink leaf, no symlinked parent, no escape), reserved-write list, and existence. Returns {ok, reason, abs}.
|
|
651
|
+
function _checkMultiFileTargets(sessionRoot, edits) {
|
|
652
|
+
if (!Array.isArray(edits) || edits.length < 2) return { ok: false, reason: 'a multi-file plan needs at least two edits' };
|
|
653
|
+
if (edits.length > MULTI_FILE_MAX) return { ok: false, reason: `a multi-file plan may edit at most ${MULTI_FILE_MAX} files` };
|
|
654
|
+
const rootReal = fs.realpathSync(path.resolve(sessionRoot));
|
|
655
|
+
const seen = new Set();
|
|
656
|
+
const abs = {};
|
|
657
|
+
for (const e of edits) {
|
|
658
|
+
const rel = e && e.file;
|
|
659
|
+
if (typeof rel !== 'string' || !rel || rel.includes('\0') || rel.includes('\\') || path.isAbsolute(rel) || rel.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) {
|
|
660
|
+
return { ok: false, reason: `path-escape refused: plan file "${path.isAbsolute(String(rel)) ? '(absolute path)' : String(rel).slice(0, 120)}" is not a clean root-relative path` };
|
|
661
|
+
}
|
|
662
|
+
if (seen.has(rel)) return { ok: false, reason: `the same file appears twice in the plan: ${rel}` };
|
|
663
|
+
seen.add(rel);
|
|
664
|
+
let real;
|
|
665
|
+
try { real = _confine(sessionRoot, rel, 'plan file'); }
|
|
666
|
+
catch (err) { return { ok: false, reason: `path-escape refused: ${String(err.message).split(rootReal).join('<root>')}` }; }
|
|
667
|
+
if (path.relative(rootReal, real).split(path.sep).join('/') !== rel) return { ok: false, reason: `path-escape refused: plan file "${rel}" resolves through a symbolic link` };
|
|
668
|
+
if (_isReservedWritePath(sessionRoot, real)) return { ok: false, reason: `reserved path refused: ${rel}` };
|
|
669
|
+
if (!fs.existsSync(real)) return { ok: false, reason: `plan file not found: ${rel}` };
|
|
670
|
+
abs[rel] = real;
|
|
671
|
+
}
|
|
672
|
+
return { ok: true, abs };
|
|
673
|
+
}
|
|
674
|
+
const _scrubRoot = (sessionRoot, text) => {
|
|
675
|
+
let out = String(text);
|
|
676
|
+
const roots = new Set([path.resolve(sessionRoot)]);
|
|
677
|
+
try { roots.add(fs.realpathSync(path.resolve(sessionRoot))); } catch { /* root vanished: the plain path is still scrubbed */ }
|
|
678
|
+
for (const r of roots) out = out.split(r).join('<root>');
|
|
679
|
+
return redactString(out);
|
|
680
|
+
};
|
|
681
|
+
const _gateSummary = (g) => (g ? {
|
|
682
|
+
path: g.path ? g.path.ok : undefined, syntax: g.syntax ? g.syntax.ok : undefined,
|
|
683
|
+
rescan: g.rescan ? { ok: g.rescan.ok, originalGone: g.rescan.originalGone, newMediumOrHigher: g.rescan.newMediumOrHigher } : undefined,
|
|
684
|
+
effective: g.effective ? { ok: g.effective.ok, ran: g.effective.ran === true, detail: g.effective.detail } : undefined,
|
|
685
|
+
compile: g.compile ? { ran: g.compile.ran === true, ok: g.compile.ok } : undefined,
|
|
686
|
+
} : null);
|
|
687
|
+
|
|
688
|
+
async function _applyMultiFilePlan({ f, planDigest, dryRun, fixMeta, ctx, status }) {
|
|
689
|
+
const refuse = (reason, extra = {}) => ({ _meta: META, applied: false, multiFile: true, reason, ...extra });
|
|
690
|
+
if (typeof planDigest !== 'string' || !/^[0-9a-f]{64}$/.test(planDigest)) return refuse('plan_digest must be the 64-character hex digest returned by synthesize_fix');
|
|
691
|
+
if (!f.stableId) return refuse('finding has no stableId, so a plan cannot be verified against it');
|
|
692
|
+
const lc = await import('../language/context.js');
|
|
693
|
+
if (lc.languageOfFinding(f) !== 'nix') return refuse('multi-file plans are produced for NixOS findings only');
|
|
694
|
+
const nf = await import('../language/nix-fix.js');
|
|
695
|
+
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
696
|
+
const files = proj.files;
|
|
697
|
+
// The plan is derived here, from the signed finding and the live tree. Nothing the caller sends can supply file content.
|
|
698
|
+
const plan = nf.planNixFix(f, files);
|
|
699
|
+
if (!plan || plan.ok === false) return refuse(`no applicable plan: ${_scrubRoot(ctx.sessionRoot, (plan && plan.reason) || 'no deterministic fix')}`);
|
|
700
|
+
if (!Array.isArray(plan.edits) || plan.edits.length < 2) return refuse('this finding has a single-file plan; use apply_fix without plan_digest');
|
|
701
|
+
if (_multiFilePlanDigest(f, plan.edits) !== planDigest) {
|
|
702
|
+
return refuse('plan_digest does not match the plan computed now for this finding: the project files or the finding changed since synthesize_fix, or the digest was altered. Run synthesize_fix again and review the new plan.', { stale: true });
|
|
703
|
+
}
|
|
704
|
+
const targets = _checkMultiFileTargets(ctx.sessionRoot, plan.edits);
|
|
705
|
+
if (!targets.ok) return refuse(targets.reason);
|
|
706
|
+
const classification = classifyFixMaterialRisk(Object.fromEntries(plan.edits.map((e) => [e.file, { before: e.before, after: e.after }])));
|
|
707
|
+
if (!dryRun) {
|
|
708
|
+
const approvalRefusal = _highImpactApprovalRefusal(ctx, classification, fixMeta);
|
|
709
|
+
if (approvalRefusal) return { ...approvalRefusal, multiFile: true };
|
|
710
|
+
}
|
|
711
|
+
// The verification gates (syntax, rescan with no new finding, effective value changed as intended) run inside the lifecycle,
|
|
712
|
+
// and the pre-write hook re-checks the digest of the VERIFIED edits and the targets immediately before the first byte is written.
|
|
713
|
+
const preWrite = (verifiedPlan) => {
|
|
714
|
+
const vedits = Array.isArray(verifiedPlan.edits) ? verifiedPlan.edits : [];
|
|
715
|
+
if (_multiFilePlanDigest(f, vedits) !== planDigest) return { ok: false, detail: 'the verified plan differs from the previewed plan' };
|
|
716
|
+
const again = _checkMultiFileTargets(ctx.sessionRoot, vedits);
|
|
717
|
+
return again.ok ? { ok: true } : { ok: false, detail: again.reason };
|
|
718
|
+
};
|
|
719
|
+
let res;
|
|
720
|
+
try { res = await nf.validateNixFix(f, { files, apply: !dryRun, root: ctx.sessionRoot, preWrite }); }
|
|
721
|
+
catch (e) { return refuse(`plan verification failed: ${_scrubRoot(ctx.sessionRoot, e.message)}`); }
|
|
722
|
+
const gates = _gateSummary(res.gates);
|
|
723
|
+
const touched = plan.edits.map((e) => e.file);
|
|
724
|
+
if (res.status === 'verified' && dryRun) {
|
|
725
|
+
return { _meta: META, applied: false, dryRun: true, verified: true, multiFile: true, files: touched, planDigest, gates, materialClassification: classification, diff: res.preview };
|
|
726
|
+
}
|
|
727
|
+
if (res.status === 'applied') {
|
|
728
|
+
let acceptance = null;
|
|
729
|
+
try { acceptance = fixAcceptanceRate(ctx.sessionRoot); } catch { /* best-effort */ }
|
|
730
|
+
const hist = Array.isArray(res.history) ? res.history : [];
|
|
731
|
+
const allRecorded = hist.length === touched.length && hist.every(Boolean);
|
|
732
|
+
return {
|
|
733
|
+
_meta: META, applied: true, verified: true, multiFile: true, files: touched, planDigest,
|
|
734
|
+
groupId: res.backup && res.backup.id, historyIds: hist.map((h) => (h ? h.id : null)),
|
|
735
|
+
...(allRecorded ? {} : { warning: 'the files were written and backed up, but the fix history could not record every entry; restore with the group backup under .agentic-security/fix-backups' }),
|
|
736
|
+
gates, integrity: status, acceptance, materialClassification: classification,
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
// Blocked or failed: nothing is left changed unless rollbackIncomplete says otherwise.
|
|
740
|
+
const incomplete = res.rolledBack === false;
|
|
741
|
+
return {
|
|
742
|
+
_meta: META, applied: false, multiFile: true, files: touched,
|
|
743
|
+
reason: _scrubRoot(ctx.sessionRoot, res.reason || `plan not applied (${res.status})`),
|
|
744
|
+
gates,
|
|
745
|
+
...(res.rolledBack !== undefined ? { rolledBack: res.rolledBack === true, rollbackIncomplete: incomplete, ...(incomplete ? { restoreManually: (res.rollbackFailed || []).map(String) } : {}) } : {}),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
606
749
|
// ─── apply_fix ───────────────────────────────────────────────────────────────
|
|
607
750
|
export const apply_fix = {
|
|
608
751
|
name: 'apply_fix',
|
|
609
|
-
description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification.',
|
|
752
|
+
description: 'Apply a fix for a finding. Two modes: (1) the stored fix.replacement, or (2) a caller-supplied `patch` (a files map) which is RE-VERIFIED inline (rescan-clean + no new ≥medium + lint) before any write — this unblocks findings that ship only a template or description. Refuses if last-scan.json fails its HMAC check, if the finding is shadow-marked, or if a path escapes the session root via lexical traversal OR a symlink. Requires confirm:true. Supports dry_run:true to preview without writing. On success, `verified:true` means verification passed but `verifiedFull:true` is the honest signal that every required leg (lint when configured, tests when a runner exists) genuinely ran — a false `verifiedFull` with `verified:true` means the pass is real but degraded (see `verify.degradedLegs`), not a full verification. MULTI-FILE NixOS FIX: when synthesize_fix returns `languageFix.planDigest` (an option defined in several files, edited together), pass it back as `plan_digest`. apply_fix then RECOMPUTES the plan itself from the signed finding and the live files, refuses unless its digest matches (so a changed tree, a changed finding or an altered digest all refuse; you never send file content), applies the same confinement, reserved-path, shadow and high-impact-approval checks to EVERY file, runs the verification gates (syntax, rescan with no new finding, the option resolves to the intended value on the patched configuration) before writing, and writes all files or none with one backup per file and one history group that `undo` reverts together. A rollback that could not complete is reported as `rollbackIncomplete` with the files to restore. `plan_digest` cannot be combined with `patch`.',
|
|
610
753
|
inputSchema: {
|
|
611
754
|
type: 'object',
|
|
612
755
|
additionalProperties: false,
|
|
@@ -614,6 +757,7 @@ export const apply_fix = {
|
|
|
614
757
|
finding_id: { type: 'string', minLength: 1, maxLength: 256 },
|
|
615
758
|
confirm: { type: 'boolean' },
|
|
616
759
|
dry_run: { type: 'boolean' },
|
|
760
|
+
plan_digest: { type: 'string', minLength: 64, maxLength: 64 },
|
|
617
761
|
patch: {
|
|
618
762
|
type: 'object',
|
|
619
763
|
additionalProperties: { type: 'string', maxLength: 500_000 },
|
|
@@ -673,7 +817,7 @@ export const apply_fix = {
|
|
|
673
817
|
},
|
|
674
818
|
required: ['finding_id', 'confirm'],
|
|
675
819
|
},
|
|
676
|
-
async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null }, ctx) {
|
|
820
|
+
async handler({ finding_id, confirm, dry_run = false, patch = null, fixMeta = null, plan_digest = null }, ctx) {
|
|
677
821
|
if (confirm !== true) {
|
|
678
822
|
return { _meta: META, applied: false, reason: 'apply_fix requires confirm: true.' };
|
|
679
823
|
}
|
|
@@ -687,6 +831,15 @@ export const apply_fix = {
|
|
|
687
831
|
return { _meta: META, applied: false, reason: 'shadow findings cannot be auto-applied' };
|
|
688
832
|
}
|
|
689
833
|
|
|
834
|
+
// Multi-file plan (a NixOS option defined in several files). The plan is recomputed here and bound by plan_digest; the
|
|
835
|
+
// caller can never supply file content on this path, and it cannot be combined with `patch`.
|
|
836
|
+
if (plan_digest !== null && plan_digest !== undefined) {
|
|
837
|
+
if (patch && typeof patch === 'object' && Object.keys(patch).length) {
|
|
838
|
+
return { _meta: META, applied: false, reason: 'plan_digest and patch cannot be combined: a multi-file plan is computed by the scanner, not supplied by the caller' };
|
|
839
|
+
}
|
|
840
|
+
return _applyMultiFilePlan({ f, planDigest: plan_digest, dryRun: dry_run === true, fixMeta, ctx, status });
|
|
841
|
+
}
|
|
842
|
+
|
|
690
843
|
// #3 — verifier-approved patch path. When the caller supplies `patch` (a
|
|
691
844
|
// files map, same shape as verify_fix), apply_fix re-runs the verifier
|
|
692
845
|
// INLINE and writes only if it passes: the original finding's stableId is
|
|
@@ -762,39 +915,8 @@ export const apply_fix = {
|
|
|
762
915
|
if (dry_run) {
|
|
763
916
|
return { _meta: META, applied: false, dryRun: true, verified: true, files: Object.keys(confinedAbs), summary: verdict.summary, materialClassification };
|
|
764
917
|
}
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
const hasApprovalEvidence = !!(approval && typeof approval === 'object' &&
|
|
768
|
-
typeof approval.approvedBy === 'string' && approval.approvedBy.trim().length > 0 &&
|
|
769
|
-
typeof approval.reason === 'string' && approval.reason.trim().length > 0);
|
|
770
|
-
if (!hasApprovalEvidence) {
|
|
771
|
-
return {
|
|
772
|
-
_meta: META, applied: false,
|
|
773
|
-
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) requires approval evidence — pass fixMeta.approval: {approvedBy, reason} — before it can be applied`,
|
|
774
|
-
materialClassification,
|
|
775
|
-
};
|
|
776
|
-
}
|
|
777
|
-
const approverRegistry = loadApproverRegistry(ctx.sessionRoot);
|
|
778
|
-
const requiredRoles = requiredRolesFor(approverRegistry, materialClassification.highImpactCategories);
|
|
779
|
-
const identityCheck = verifyApprover(approverRegistry, approval.approvedBy, requiredRoles);
|
|
780
|
-
if (!identityCheck.verified) {
|
|
781
|
-
return {
|
|
782
|
-
_meta: META, applied: false,
|
|
783
|
-
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${identityCheck.reason}`,
|
|
784
|
-
materialClassification,
|
|
785
|
-
};
|
|
786
|
-
}
|
|
787
|
-
// FR-1003: separation-of-duties, same no-op-unless-configured gate
|
|
788
|
-
// as apply-fix-service.js's own copy — see approver-registry.js.
|
|
789
|
-
const sodCheck = checkSeparationOfDuties(approverRegistry, fixMeta?.author, approval.approvedBy);
|
|
790
|
-
if (!sodCheck.ok) {
|
|
791
|
-
return {
|
|
792
|
-
_meta: META, applied: false,
|
|
793
|
-
reason: `high-impact change (${materialClassification.highImpactCategories.join(', ')}) approval rejected: ${sodCheck.reason}`,
|
|
794
|
-
materialClassification,
|
|
795
|
-
};
|
|
796
|
-
}
|
|
797
|
-
}
|
|
918
|
+
const approvalRefusal = _highImpactApprovalRefusal(ctx, materialClassification, fixMeta);
|
|
919
|
+
if (approvalRefusal) return approvalRefusal;
|
|
798
920
|
const written = [];
|
|
799
921
|
try {
|
|
800
922
|
for (const [rel, v] of Object.entries(confinedAbs)) {
|
|
@@ -1025,7 +1147,7 @@ export const verify_fix = {
|
|
|
1025
1147
|
// sequence with the returned blob.
|
|
1026
1148
|
export const synthesize_fix = {
|
|
1027
1149
|
name: 'synthesize_fix',
|
|
1028
|
-
description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy.',
|
|
1150
|
+
description: 'Return the stored fix replacement for a finding (replacement text + remediation + plan if the patch is too large). Read-only; never writes to disk. Use verify_fix → apply_fix to deploy. For a NixOS option defined in several files, `languageFix.multiFile` lists every file, `languageFix.edits` gives each file\'s before/after SHA-256 (no file content) and `languageFix.planDigest` is the value to pass to apply_fix as `plan_digest`; the preview is verified but nothing is written.',
|
|
1029
1151
|
inputSchema: {
|
|
1030
1152
|
type: 'object',
|
|
1031
1153
|
additionalProperties: false,
|
|
@@ -1079,8 +1201,15 @@ export const synthesize_fix = {
|
|
|
1079
1201
|
const proj = lc.loadLanguageProject(ctx.sessionRoot);
|
|
1080
1202
|
const prev = await lc.languageFixPreview(f, proj.files);
|
|
1081
1203
|
languageFix = { status: prev.status, ok: prev.ok, label: prev.label || null, tier: prev.tier || null, reason: prev.reason || null, diff: prev.diff || null, explanation: prev.explanation || null, consequences: prev.consequences || [] };
|
|
1082
|
-
//
|
|
1083
|
-
if (prev.ok && prev.edits)
|
|
1204
|
+
// A fix that spans several files is applied with apply_fix + plan_digest (atomic, one history group); see _applyMultiFilePlan.
|
|
1205
|
+
if (prev.ok && prev.edits) {
|
|
1206
|
+
languageFix.multiFile = prev.edits.map((e) => e.file);
|
|
1207
|
+
// The digest binds finding + every file + exact pre/post image. Pass it to apply_fix as plan_digest; apply_fix
|
|
1208
|
+
// recomputes the plan itself and refuses on any difference. No file content is ever sent back to apply_fix.
|
|
1209
|
+
languageFix.planDigest = _multiFilePlanDigest(f, prev.edits);
|
|
1210
|
+
languageFix.edits = prev.edits.map((e) => ({ file: e.file, beforeSha256: _sha256(e.before), afterSha256: _sha256(e.after) }));
|
|
1211
|
+
languageFix.applyWith = { finding_id: f.id, confirm: true, plan_digest: languageFix.planDigest };
|
|
1212
|
+
}
|
|
1084
1213
|
else if (prev.ok) autofix = { deterministic: true, ruleId: f.rule || f.family || null, patch: prev.after, file: prev.file, label: prev.label || null, verified: true };
|
|
1085
1214
|
}
|
|
1086
1215
|
} catch { /* best-effort: the preview is advisory */ }
|
|
@@ -734,8 +734,12 @@ function buildNarrative(finding, signals) {
|
|
|
734
734
|
` (controls reducing cost: ${[...signals.controls].join(', ')})` : '';
|
|
735
735
|
const line = finding.line || finding.source?.line || finding.sink?.line || 0;
|
|
736
736
|
|
|
737
|
+
// A dependency advisory (Hackage, Nix) carries no `vuln` text, and `vuln` also feeds finding identity, so it is not invented there: the
|
|
738
|
+
// narrative builds its own label from what the finding does state, and leaves out a location clause it has no location for.
|
|
739
|
+
const label = finding.vuln || finding.title || advisoryLabel(finding);
|
|
740
|
+
const where = finding.file ? ` on \`${finding.file}:${line}\`` : '';
|
|
737
741
|
const narrative =
|
|
738
|
-
`${
|
|
742
|
+
`${label}${where} ` +
|
|
739
743
|
`could ${dataAtRisk.includes('rce') ? 'lead to' : 'expose'} ${dataPhrase}. ` +
|
|
740
744
|
`Context: ${indProfile.label} / ${jurStr}${controlsStr}. ` +
|
|
741
745
|
`Estimated cost: best ${fmtMoney(total.low)} · likely ${fmtMoney(total.likely)} · worst ${fmtMoney(total.high)}. ` +
|
|
@@ -778,6 +782,16 @@ function buildNarrative(finding, signals) {
|
|
|
778
782
|
};
|
|
779
783
|
}
|
|
780
784
|
|
|
785
|
+
/** A truthful label for a finding with no `vuln` or `title`: the advisory and package it names, else a plain "Finding". */
|
|
786
|
+
function advisoryLabel(f) {
|
|
787
|
+
const id = f.osvId || f.advisory || (Array.isArray(f.ids) && f.ids[0]) || null;
|
|
788
|
+
const pkg = f.name ? `${f.name}${f.version ? ` ${f.version}` : ''}` : null;
|
|
789
|
+
if (id && pkg) return `Advisory ${id} in ${pkg}`;
|
|
790
|
+
if (id) return `Advisory ${id}`;
|
|
791
|
+
if (pkg) return `Vulnerable dependency ${pkg}`;
|
|
792
|
+
return 'Finding';
|
|
793
|
+
}
|
|
794
|
+
|
|
781
795
|
function describeDataAtRisk(dataAtRisk, userCount) {
|
|
782
796
|
const n = userCount.toLocaleString();
|
|
783
797
|
if (dataAtRisk.includes('rce')) return 'full server compromise (remote code execution)';
|
|
@@ -213,7 +213,11 @@ async function withLock(lockPath, fn) {
|
|
|
213
213
|
fsp.readFile(lockPath, 'utf8').catch(() => ''),
|
|
214
214
|
]);
|
|
215
215
|
const pid = parseInt(pidStr.trim(), 10);
|
|
216
|
-
|
|
216
|
+
// A holder creates the file (`wx`) and only then writes its PID, so
|
|
217
|
+
// an EMPTY file younger than a few seconds is a lock being taken,
|
|
218
|
+
// not a dead one. Reaping it would hand the lock to two processes.
|
|
219
|
+
const mid = !pidStr.trim() && Date.now() - st.mtimeMs < 2000;
|
|
220
|
+
const pidAlive = mid || (Number.isFinite(pid) && isProcessAlive(pid));
|
|
217
221
|
const old = Date.now() - st.mtimeMs > 30000;
|
|
218
222
|
if (!pidAlive || old) {
|
|
219
223
|
try {
|
|
@@ -225,6 +229,11 @@ async function withLock(lockPath, fn) {
|
|
|
225
229
|
await fsp.unlink(lockPath);
|
|
226
230
|
}
|
|
227
231
|
} catch {}
|
|
232
|
+
// Bounded: a reap that cannot make progress (unlink refused, the
|
|
233
|
+
// holder changing under us) must end in the named timeout error,
|
|
234
|
+
// never spin until an outer watchdog kills the process.
|
|
235
|
+
if (Date.now() - start > TIMEOUT_MS) throw new Error('remediation-ledger: lock timed out');
|
|
236
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
228
237
|
continue;
|
|
229
238
|
}
|
|
230
239
|
} catch {}
|
package/src/sast/pipeline.js
CHANGED
|
@@ -13,6 +13,44 @@
|
|
|
13
13
|
const _GH_WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/.*\.ya?ml$/i;
|
|
14
14
|
const _NONPROD_RE = /(?:^|\/)(?:tests?|examples?|fixtures?)\//i;
|
|
15
15
|
|
|
16
|
+
|
|
17
|
+
// Matches of the workflow-wide `env:` secret pattern, as { index, end }. See the entry in PIPELINE_PATTERNS.
|
|
18
|
+
// The header is `^env\s*:\s*\n`; its greedy `\s*` ends at the last newline of the whitespace run, and every earlier choice
|
|
19
|
+
// of newline only replays the same lines, so the lazy line loop starts at the line after that newline. Each following line
|
|
20
|
+
// is either the assignment (the tail, tried first, as the lazy loop does) or must itself start with a space or tab.
|
|
21
|
+
const _ENV_HEAD_RE = /^env\s*:\s*/gm;
|
|
22
|
+
const _ENV_TAIL_RE = /[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/y;
|
|
23
|
+
export function* scanEnvSecrets(raw, stats) {
|
|
24
|
+
const head = new RegExp(_ENV_HEAD_RE.source, _ENV_HEAD_RE.flags);
|
|
25
|
+
const tail = new RegExp(_ENV_TAIL_RE.source, _ENV_TAIL_RE.flags);
|
|
26
|
+
let pos = 0;
|
|
27
|
+
while (pos <= raw.length) {
|
|
28
|
+
head.lastIndex = pos;
|
|
29
|
+
const h = head.exec(raw);
|
|
30
|
+
if (!h) return;
|
|
31
|
+
const afterHead = h.index + h[0].length;
|
|
32
|
+
const colon = raw.indexOf(':', h.index + 3);
|
|
33
|
+
const lastNl = raw.lastIndexOf('\n', afterHead - 1);
|
|
34
|
+
if (lastNl <= colon) { pos = h.index + 1; continue; } // no newline after the colon: not a header
|
|
35
|
+
let s = lastNl + 1;
|
|
36
|
+
let end = -1;
|
|
37
|
+
for (;;) {
|
|
38
|
+
tail.lastIndex = s;
|
|
39
|
+
if (stats) stats.tailAttempts += 1;
|
|
40
|
+
const t = tail.exec(raw);
|
|
41
|
+
if (t) { end = s + t[0].length; break; }
|
|
42
|
+
const c = raw.charCodeAt(s);
|
|
43
|
+
if (c !== 32 && c !== 9) break; // the line loop needs an indented line
|
|
44
|
+
const nl = raw.indexOf('\n', s);
|
|
45
|
+
if (nl < 0) break;
|
|
46
|
+
s = nl + 1;
|
|
47
|
+
}
|
|
48
|
+
if (end < 0) { pos = h.index + 1; continue; }
|
|
49
|
+
yield { index: h.index, end };
|
|
50
|
+
pos = end > h.index ? end : h.index + 1;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
16
54
|
const PIPELINE_PATTERNS = [
|
|
17
55
|
// ── build hooks common to Haskell (ghcup, Cabal, Stack, Hackage) and Nix pipelines ──
|
|
18
56
|
{
|
|
@@ -34,7 +72,12 @@ const PIPELINE_PATTERNS = [
|
|
|
34
72
|
fix: 'Do not accept a flake\'s own configuration, disable the sandbox, add unauthenticated substituters or evaluate impurely in CI. Put the trust decision in the repository\'s reviewed configuration instead.',
|
|
35
73
|
},
|
|
36
74
|
{
|
|
37
|
-
|
|
75
|
+
// Equivalent to /^env\s*:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gm,
|
|
76
|
+
// which backtracked catastrophically on a comment-blanked workflow (blanking leaves runs of blank lines, and `\s*`, `\n` and
|
|
77
|
+
// the lazy line loop can all claim them): a real repository's CI directory made a scan run for many minutes. The matcher
|
|
78
|
+
// below returns the same match starts and ends, in one pass over each `env:` block. Pinned against the original expression
|
|
79
|
+
// by test/pipeline-env-secret.test.js.
|
|
80
|
+
scan: scanEnvSecrets,
|
|
38
81
|
vuln: 'Pipeline: secret exposed at workflow-wide environment scope',
|
|
39
82
|
sev: 'medium', cwe: 'CWE-522',
|
|
40
83
|
fix: 'Set secrets such as a Hackage or Cachix token on the single step that needs them (`env:` under that step), not at the top of the workflow where every step and every third-party action can read them.',
|
|
@@ -105,9 +148,13 @@ export function scanPipeline(fp, raw) {
|
|
|
105
148
|
if (p.contextNeg && present) continue; // suppress: required context exists
|
|
106
149
|
if (!p.contextNeg && !present) continue;
|
|
107
150
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
let matches;
|
|
152
|
+
if (p.scan) matches = p.scan(raw);
|
|
153
|
+
else {
|
|
154
|
+
const re = new RegExp(p.re.source, p.re.flags.includes('g') ? p.re.flags : p.re.flags + 'g');
|
|
155
|
+
matches = (function* () { let mm; while ((mm = re.exec(raw))) yield mm; })();
|
|
156
|
+
}
|
|
157
|
+
for (const m of matches) {
|
|
111
158
|
const line = raw.substring(0, m.index).split('\n').length;
|
|
112
159
|
if (p.lineSafeRe && p.lineSafeRe.test((lines[line - 1] || '').trim())) continue;
|
|
113
160
|
const id = `pipeline:${fp}:${line}:${p.vuln.replace(/\s/g, '_').slice(0, 48)}`;
|