@clear-capabilities/agentic-security-scanner 0.160.0 → 0.162.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.
Files changed (76) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/bin/agentic-security.js +11 -2
  3. package/dist/1919.index.js +1 -1
  4. package/dist/2432.index.js +10 -1
  5. package/dist/4113.index.js +60 -9
  6. package/dist/5613.index.js +500 -0
  7. package/dist/6239.index.js +469 -0
  8. package/dist/7033.index.js +4 -1
  9. package/dist/7838.index.js +39 -3
  10. package/dist/8714.index.js +1 -1
  11. package/dist/8798.index.js +2 -2
  12. package/dist/9207.index.js +5 -0
  13. package/dist/9560.index.js +205 -62
  14. package/dist/{1905.index.js → 9999.index.js} +265 -8
  15. package/dist/agentic-security.mjs +12 -12
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +14 -9
  18. package/src/dataflow/engine.js +20 -4
  19. package/src/discovery/CLAUDE.md +9 -0
  20. package/src/discovery/index.js +28 -0
  21. package/src/discovery/memory.js +7 -5
  22. package/src/engine.js +29 -8
  23. package/src/fix/apply-fix-service.js +33 -3
  24. package/src/language/CLAUDE.md +6 -1
  25. package/src/language/aibom.js +4 -1
  26. package/src/language/fix-lifecycle.js +9 -0
  27. package/src/language/haskell-ir.js +47 -7
  28. package/src/language/haskell-sca.js +87 -7
  29. package/src/language/haskell-supply.js +175 -6
  30. package/src/language/haskell-web.js +200 -18
  31. package/src/language/nix-fix.js +1 -1
  32. package/src/language/resolved-pass.js +4 -1
  33. package/src/language/witness.js +1 -1
  34. package/src/mcp/CLAUDE.md +43 -4
  35. package/src/mcp/tools.js +185 -43
  36. package/src/posture/CLAUDE.md +174 -0
  37. package/src/posture/assurance/baseline-inventory.js +218 -0
  38. package/src/posture/assurance/baseline.js +322 -0
  39. package/src/posture/assurance/bounded-io.js +145 -0
  40. package/src/posture/assurance/config.js +299 -0
  41. package/src/posture/assurance/contracts.js +315 -0
  42. package/src/posture/assurance/identity.js +62 -0
  43. package/src/posture/assurance/migrations.js +259 -0
  44. package/src/posture/assurance/schema-kit.js +111 -0
  45. package/src/posture/assurance/verification-record.js +258 -0
  46. package/src/posture/attestation.js +5 -0
  47. package/src/posture/autopilot.js +28 -1
  48. package/src/posture/blast-radius.js +15 -1
  49. package/src/posture/evidence-bundle.js +93 -7
  50. package/src/posture/execution-proof.js +11 -1
  51. package/src/posture/fix-verify-loop.js +20 -6
  52. package/src/posture/fix-verify.js +37 -2
  53. package/src/posture/oracles/adapters.js +360 -0
  54. package/src/posture/oracles/conformance.js +275 -0
  55. package/src/posture/oracles/oracle.js +433 -0
  56. package/src/posture/oracles/registry.js +76 -0
  57. package/src/posture/oracles/scenario-classes.js +223 -0
  58. package/src/posture/remediation-ledger.js +10 -1
  59. package/src/posture/replay/replay.js +249 -0
  60. package/src/posture/verification/advisory-state.js +71 -0
  61. package/src/posture/verification/emit.js +257 -0
  62. package/src/posture/verification/hypothesis-promotion.js +211 -0
  63. package/src/posture/verification/patch-negative.js +214 -0
  64. package/src/posture/verification/patch-promotion.js +105 -0
  65. package/src/posture/verification/projection.js +183 -0
  66. package/src/posture/verification/repair-records.js +124 -0
  67. package/src/posture/verifier.js +16 -0
  68. package/src/report/index.js +49 -0
  69. package/src/sandbox/CLAUDE.md +41 -0
  70. package/src/sandbox/backend-namespace.js +7 -0
  71. package/src/sandbox/backend-userspace.js +68 -29
  72. package/src/sandbox/control-probes.js +215 -0
  73. package/src/sandbox/supervise.js +201 -0
  74. package/src/sandbox/trust-boundary.js +113 -0
  75. package/src/sandbox/trust-domains.js +171 -0
  76. package/src/sast/pipeline.js +51 -4
@@ -0,0 +1,201 @@
1
+ // Supervised execution: every process the target starts, and all of its
2
+ // descendants, end when the run ends (CORE-003.AC02).
3
+ //
4
+ // `spawnSync`'s timeout signals only the direct child (see CLAUDE.md, "Timeout
5
+ // does not kill the process tree"). This module is the repair for the backends
6
+ // where it can be proved:
7
+ //
8
+ // 1. The child starts in its own process group and session (`detached`), so
9
+ // one signal to the negative pid reaches every member that did not move.
10
+ // 2. While it runs, the process table is swept on a short interval and every
11
+ // descendant seen is remembered, so a member that left the group (for
12
+ // example via setsid) while its parent was still alive is still killed.
13
+ // 3. Termination is SIGTERM to the group and the tracked set, a grace period,
14
+ // then SIGKILL, then a poll that CONFIRMS nothing is left. Survivors are
15
+ // reported, never assumed away.
16
+ // 4. The same cleanup runs on a normal exit, so a backgrounded grandchild
17
+ // cannot outlive a run that "succeeded".
18
+ //
19
+ // KNOWN GAP (not hidden): a descendant that double-forks and calls setsid
20
+ // between two sweeps, with its parent exiting before the next sweep, is
21
+ // reparented away from the tree and is not found. The sweep interval bounds the
22
+ // window; it does not close it. Only a PID-namespace or cgroup boundary closes
23
+ // it, and neither is available on the macOS backend.
24
+ import { spawn, spawnSync } from 'node:child_process';
25
+ import { detectBackend } from './capabilities.js';
26
+ import { runDisabled } from './backend-disabled.js';
27
+ import { buildUserspaceInvocation } from './backend-userspace.js';
28
+ import { buildResult, errorResult } from './result.js';
29
+
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
31
+
32
+ function alive(pid) {
33
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
34
+ }
35
+ function groupAlive(pgid) {
36
+ try { process.kill(-pgid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
37
+ }
38
+ function signalPid(pid, sig) { try { process.kill(pid, sig); } catch { /* already gone */ } }
39
+ function signalGroup(pgid, sig) { try { process.kill(-pgid, sig); } catch { /* already gone */ } }
40
+
41
+ /** Descendants of `rootPid` plus members of its group, from one `ps` snapshot. */
42
+ function snapshotTree(rootPid) {
43
+ const r = spawnSync('ps', ['-A', '-o', 'pid=,ppid=,pgid='], { encoding: 'utf8', timeout: 2000 });
44
+ if (r.error || r.status !== 0) return [];
45
+ const rows = [];
46
+ for (const line of String(r.stdout).split('\n')) {
47
+ const m = line.trim().split(/\s+/).map(Number);
48
+ if (m.length === 3 && m.every(Number.isFinite)) rows.push({ pid: m[0], ppid: m[1], pgid: m[2] });
49
+ }
50
+ const kids = new Map();
51
+ for (const x of rows) { if (!kids.has(x.ppid)) kids.set(x.ppid, []); kids.get(x.ppid).push(x.pid); }
52
+ const found = new Set();
53
+ const queue = [rootPid];
54
+ while (queue.length) {
55
+ const p = queue.pop();
56
+ for (const c of kids.get(p) || []) if (!found.has(c)) { found.add(c); queue.push(c); }
57
+ }
58
+ for (const x of rows) if (x.pgid === rootPid && x.pid !== process.pid) found.add(x.pid);
59
+ return [...found];
60
+ }
61
+
62
+ function _emptyTermination() { return { signalled: null, killedPids: [], survivors: [] }; }
63
+
64
+ /**
65
+ * Spawn `bin args` as a supervised process tree.
66
+ * @returns {Promise<object>} never rejects.
67
+ */
68
+ export function superviseSpawn(bin, args, {
69
+ cwd, env, timeoutMs = 10000, graceMs = 1000, signal, maxOutputBytes = 8 * 1024 * 1024, sweepMs = 100,
70
+ } = {}) {
71
+ return new Promise((resolve) => {
72
+ let child;
73
+ try {
74
+ child = spawn(bin, args, { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
75
+ } catch (e) {
76
+ resolve({ spawnError: e.message, exitCode: null, stdout: '', stderr: '', timedOut: false, cancelled: false, outputCapped: false, termination: _emptyTermination() });
77
+ return;
78
+ }
79
+ const pgid = child.pid;
80
+ const tracked = new Set();
81
+ let stdout = '', stderr = '', outBytes = 0;
82
+ let timedOut = false, cancelled = false, outputCapped = false, spawnError = null;
83
+ let exitCode = null, exitSignal = null;
84
+ let finishing = null;
85
+ let timer = null;
86
+
87
+ const sweep = () => { if (pgid) for (const p of snapshotTree(pgid)) tracked.add(p); };
88
+ const sweeper = setInterval(sweep, sweepMs);
89
+
90
+ const survivors = () => {
91
+ sweep();
92
+ return { live: [...tracked].filter(alive), group: groupAlive(pgid) };
93
+ };
94
+
95
+ async function terminate() {
96
+ const t = _emptyTermination();
97
+ const before = survivors();
98
+ if (!before.group && before.live.length === 0) return t;
99
+ t.signalled = 'SIGTERM';
100
+ signalGroup(pgid, 'SIGTERM');
101
+ for (const p of tracked) signalPid(p, 'SIGTERM');
102
+ const deadline = Date.now() + graceMs;
103
+ while (Date.now() < deadline) {
104
+ const s = survivors();
105
+ if (!s.group && s.live.length === 0) return t;
106
+ await sleep(25);
107
+ }
108
+ // Grace expired: SIGKILL cannot be ignored. Repeat until nothing is left,
109
+ // because a dying process can fork once more before it is reaped.
110
+ t.signalled = 'SIGKILL';
111
+ const hard = Date.now() + 3000;
112
+ while (Date.now() < hard) {
113
+ const s = survivors();
114
+ if (!s.group && s.live.length === 0) break;
115
+ signalGroup(pgid, 'SIGKILL');
116
+ for (const p of s.live) { signalPid(p, 'SIGKILL'); if (!t.killedPids.includes(p)) t.killedPids.push(p); }
117
+ await sleep(25);
118
+ }
119
+ const s = survivors();
120
+ t.survivors = s.live.length ? s.live : (s.group ? [-pgid] : []);
121
+ return t;
122
+ }
123
+
124
+ function finish() {
125
+ if (finishing) return finishing;
126
+ finishing = (async () => {
127
+ clearInterval(sweeper); clearTimeout(timer);
128
+ const termination = await terminate();
129
+ try { child.stdout?.destroy(); child.stderr?.destroy(); } catch { /* ignore */ }
130
+ resolve({ spawnError, exitCode, exitSignal, stdout, stderr, timedOut, cancelled, outputCapped, termination });
131
+ })();
132
+ return finishing;
133
+ }
134
+
135
+ const collect = (which) => (d) => {
136
+ outBytes += d.length;
137
+ if (outBytes > maxOutputBytes) {
138
+ if (!outputCapped) { outputCapped = true; finish(); }
139
+ return;
140
+ }
141
+ if (which === 'out') stdout += d.toString('utf8'); else stderr += d.toString('utf8');
142
+ };
143
+ child.stdout.on('data', collect('out'));
144
+ child.stderr.on('data', collect('err'));
145
+ child.on('error', (e) => { spawnError = e.message; finish(); });
146
+ child.on('exit', (code, sig) => {
147
+ exitCode = code; exitSignal = sig;
148
+ // Let buffered output drain, then clean up any survivors.
149
+ setTimeout(finish, 50);
150
+ });
151
+ timer = setTimeout(() => { timedOut = true; finish(); }, timeoutMs);
152
+ if (signal) {
153
+ if (signal.aborted) { cancelled = true; finish(); }
154
+ else signal.addEventListener('abort', () => { cancelled = true; finish(); }, { once: true });
155
+ }
156
+ });
157
+ }
158
+
159
+ /**
160
+ * `runConfined`, plus a guarantee about the process tree. Same result shape as
161
+ * every backend, with extra `cancelled`, `outputCapped`, `termination` and
162
+ * `supervised: true`. Never throws.
163
+ *
164
+ * Backend coverage, stated plainly:
165
+ * userspace supervised (verified by execution on macOS)
166
+ * namespace REFUSED: tree termination has not been implemented and executed
167
+ * on the kernel-namespace backend, so the run does not happen
168
+ * disabled refused, as always
169
+ */
170
+ export async function runConfinedSupervised(argv, opts = {}) {
171
+ const backend = detectBackend({ force: opts.force });
172
+ if (backend === 'disabled') return runDisabled(argv, opts);
173
+ if (backend !== 'userspace') {
174
+ return errorResult(backend, `process-tree termination is not implemented or verified on the ${backend} backend; refusing to execute`);
175
+ }
176
+ const inv = buildUserspaceInvocation(argv, opts);
177
+ if (inv.error) return inv.error;
178
+ const r = await superviseSpawn(inv.bin, inv.args, {
179
+ cwd: inv.cwd, env: inv.env, timeoutMs: opts.timeoutMs ?? 10000, graceMs: opts.graceMs ?? 1000,
180
+ signal: opts.signal, maxOutputBytes: opts.maxOutputBytes,
181
+ });
182
+ const res = buildResult({
183
+ backend: 'userspace',
184
+ spawnResult: {
185
+ status: r.exitCode,
186
+ stdout: r.stdout,
187
+ stderr: r.stderr,
188
+ error: r.timedOut ? { code: 'ETIMEDOUT' } : r.spawnError ? { code: 'SPAWN', message: r.spawnError } : null,
189
+ },
190
+ unsupported: inv.unsupported,
191
+ });
192
+ let status = res.status;
193
+ let stderr = res.stderr;
194
+ if (r.cancelled) status = 'cancelled';
195
+ if (r.outputCapped) { status = 'error'; stderr += '\n[sandbox] output cap exceeded; process tree terminated'; }
196
+ if (r.termination.survivors.length) { status = 'error'; stderr += '\n[sandbox] processes survived termination'; }
197
+ return {
198
+ ...res, status, stderr, cancelled: r.cancelled, outputCapped: r.outputCapped,
199
+ supervised: true, termination: r.termination,
200
+ };
201
+ }
@@ -0,0 +1,113 @@
1
+ // The execution boundary between target/worker code and the verifier
2
+ // (CORE-003). Every verifier-side execution of untrusted code goes through
3
+ // `runInBoundary`; it is where the trust domains are ENFORCED rather than
4
+ // described.
5
+ //
6
+ // Order of operations, and why each step exists:
7
+ // 1. Refuse secret material in the environment bound for the target.
8
+ // 2. Refuse a workspace that overlaps a protected path (a root that CONTAINS
9
+ // the key directory would hand the target the keys by construction).
10
+ // 3. Probe the backend's controls actively (cached per process). Any required
11
+ // control that is not `proved` makes the result `blocked` and NOTHING runs.
12
+ // Insufficient isolation is never a pass and never a warning.
13
+ // 4. Run supervised, with protected paths read-denied, a scrubbed environment
14
+ // and a process-tree deadline.
15
+ // 5. Report. The status is formed from what the boundary observed, never from
16
+ // what the target printed; target output is returned labelled untrusted.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { detectBackend } from './capabilities.js';
20
+ import { runConfinedSupervised } from './supervise.js';
21
+ import { probeControls, unmetControls } from './control-probes.js';
22
+ import {
23
+ protectedReadPaths, assertNoSecretMaterial, classifyRun, countStatusLikeLines,
24
+ } from './trust-domains.js';
25
+
26
+ export const DEFAULT_REQUIRED_CONTROLS = Object.freeze([
27
+ 'write-confinement', 'read-denial', 'env-scrub', 'tree-termination', 'file-size-limit',
28
+ ]);
29
+
30
+ function _real(p) { try { return fs.realpathSync(p); } catch { return path.resolve(p); } }
31
+ function _overlaps(a, b) {
32
+ const x = _real(a), y = _real(b);
33
+ return x === y || x.startsWith(y + path.sep) || y.startsWith(x + path.sep);
34
+ }
35
+
36
+ function _blocked(reasons, extra = {}) {
37
+ return {
38
+ status: 'blocked', blocked: true, executed: false, denied: false, reasons,
39
+ verificationStatus: 'not-run', targetOutput: null, ...extra,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * @param {string[]} argv
45
+ * @param {object} o
46
+ * @param {string} o.root the target's only writable directory
47
+ * @param {string[]} [o.labelDirs] sealed-label directories (read-denied)
48
+ * @param {string[]} [o.evidenceDirs] authoritative-evidence directories (read-denied, and write-denied by confinement)
49
+ * @param {string[]} [o.denyReadPaths] further host paths to read-deny
50
+ * @param {object} [o.env] explicit variables for the target; secret-looking ones are refused
51
+ * @param {string[]} [o.require] controls that must be `proved`
52
+ * @param {string} [o.home] home directory whose credential dirs are denied
53
+ * @param {boolean} [o.allowNetwork]
54
+ * @param {AbortSignal} [o.signal] cancel
55
+ * @param {object} [o.controlProbes] probe overrides (test seam)
56
+ */
57
+ export async function runInBoundary(argv, o = {}) {
58
+ const {
59
+ root, labelDirs = [], evidenceDirs = [], denyReadPaths = [], env = {},
60
+ allowNetwork = false, timeoutMs = 10000, graceMs = 1000, signal, force, limits,
61
+ maxOutputBytes, controlProbes,
62
+ } = o;
63
+ const required = [...(o.require || DEFAULT_REQUIRED_CONTROLS)];
64
+ if (!allowNetwork && !required.includes('network')) required.push('network');
65
+
66
+ if (!root || typeof root !== 'string') return _blocked(['no workspace root']);
67
+ const backend = detectBackend({ force });
68
+ if (backend === 'disabled') {
69
+ return _blocked(['no confinement backend works on this host; the target was not executed'], { backend });
70
+ }
71
+
72
+ const secrets = assertNoSecretMaterial(env);
73
+ if (!secrets.ok) {
74
+ return _blocked([`secret-looking variables refused for the target: ${secrets.offenders.join(', ')}`], { backend });
75
+ }
76
+
77
+ const protectedPaths = protectedReadPaths({
78
+ ...(o.home ? { home: o.home } : {}), labelDirs, evidenceDirs, extra: denyReadPaths,
79
+ });
80
+ const clash = protectedPaths.filter((p) => _overlaps(root, p));
81
+ if (clash.length) {
82
+ return _blocked([`the workspace overlaps protected path(s): ${clash.join(', ')}`], { backend });
83
+ }
84
+
85
+ const report = await probeControls({ force, probes: controlProbes || {} });
86
+ const unmet = unmetControls(report, required);
87
+ if (unmet.length) {
88
+ return _blocked(
89
+ unmet.map((u) => `control '${u.control}' is ${u.state}: ${u.reason}`),
90
+ { backend, controls: report.controls },
91
+ );
92
+ }
93
+
94
+ const r = await runConfinedSupervised(argv, {
95
+ root, timeoutMs, graceMs, signal, force, allowNetwork, limits, maxOutputBytes,
96
+ env, denyReadPaths: protectedPaths,
97
+ });
98
+ const executed = r.status !== 'disabled' && !(r.status === 'error' && !r.supervised);
99
+ const run = { ...r, executed };
100
+ return {
101
+ status: r.status, blocked: false, executed, denied: r.denied, backend: r.backend,
102
+ reasons: [], controls: report.controls,
103
+ verificationStatus: classifyRun(run),
104
+ // Target output is DATA. It is capped and labelled; it is never parsed for
105
+ // a status. The count below is informational only.
106
+ targetOutput: {
107
+ trust: 'untrusted', stdout: r.stdout, stderr: r.stderr,
108
+ statusLikeLines: countStatusLikeLines(r.stdout) + countStatusLikeLines(r.stderr),
109
+ },
110
+ exitCode: r.exitCode, timedOut: r.timedOut, cancelled: !!r.cancelled,
111
+ outputCapped: !!r.outputCapped, termination: r.termination,
112
+ };
113
+ }
@@ -0,0 +1,171 @@
1
+ // Trust domains for verification (CORE-003).
2
+ //
3
+ // Four domains stay separate, and this module is the single place that says
4
+ // which one may do what. It is deliberately data plus small pure functions: the
5
+ // ENFORCEMENT lives at the process boundary (`trust-boundary.js`, the sandbox
6
+ // profile, file modes), not here. A table nobody consults at the boundary would
7
+ // be documentation pretending to be a control, so every entry below has an
8
+ // attack test that tries the forbidden action through the real boundary.
9
+ //
10
+ // target analyzed code and anything it spawns. Adversarial by assumption.
11
+ // worker the model/agent that proposes hypotheses, scenarios and patches.
12
+ // Adversarial for authority purposes: may be prompt-injected by the
13
+ // target. Authors proposals, never truth.
14
+ // verifier the protected verifier / evaluation custodian. Holds sealed labels,
15
+ // runs oracles, writes authoritative evidence.
16
+ // signer holds signing key material and issues signatures over evidence the
17
+ // verifier produced. Never executes target or worker code.
18
+ //
19
+ // What a lower-trust domain may NEVER do, whatever it prints or writes:
20
+ // - read signing keys, the install HMAC key or sealed labels
21
+ // - write authoritative evidence
22
+ // - set a verification status (only verifier-observed evidence can)
23
+ // - read host paths unrelated to its own workspace (credential directories)
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+
27
+ export const DOMAINS = Object.freeze({
28
+ TARGET: 'target',
29
+ WORKER: 'worker',
30
+ VERIFIER: 'verifier',
31
+ SIGNER: 'signer',
32
+ });
33
+
34
+ export const RESOURCES = Object.freeze({
35
+ SIGNING_KEY: 'signing-key',
36
+ HMAC_KEY: 'hmac-key',
37
+ SEALED_LABELS: 'sealed-labels',
38
+ AUTHORITATIVE_EVIDENCE: 'authoritative-evidence',
39
+ VERIFICATION_STATUS: 'verification-status',
40
+ HOST_CREDENTIALS: 'host-credentials',
41
+ WORKSPACE: 'workspace',
42
+ });
43
+
44
+ // domain -> resource -> allowed actions. Absence means DENIED (default deny).
45
+ const POLICY = Object.freeze({
46
+ [DOMAINS.TARGET]: { [RESOURCES.WORKSPACE]: ['read', 'write'] },
47
+ [DOMAINS.WORKER]: { [RESOURCES.WORKSPACE]: ['read', 'write'] },
48
+ [DOMAINS.VERIFIER]: {
49
+ [RESOURCES.WORKSPACE]: ['read'],
50
+ [RESOURCES.SEALED_LABELS]: ['read'],
51
+ [RESOURCES.AUTHORITATIVE_EVIDENCE]: ['read', 'write'],
52
+ [RESOURCES.VERIFICATION_STATUS]: ['write'],
53
+ [RESOURCES.HMAC_KEY]: ['read'],
54
+ },
55
+ [DOMAINS.SIGNER]: {
56
+ [RESOURCES.SIGNING_KEY]: ['read'],
57
+ [RESOURCES.AUTHORITATIVE_EVIDENCE]: ['read'],
58
+ },
59
+ });
60
+
61
+ /** Default-deny policy lookup. */
62
+ export function mayDo(domain, resource, action) {
63
+ return !!POLICY[domain]?.[resource]?.includes(action);
64
+ }
65
+
66
+ /** Domains that execute or are influenced by untrusted content. */
67
+ export function isUntrusted(domain) {
68
+ return domain === DOMAINS.TARGET || domain === DOMAINS.WORKER;
69
+ }
70
+
71
+ // ---- protected host paths --------------------------------------------------
72
+
73
+ /** Config directory holding the Ed25519 keys and the install HMAC key. */
74
+ export function keyDirectory(env = process.env) {
75
+ const xdg = env.XDG_CONFIG_HOME;
76
+ const base = xdg && xdg.length ? xdg : path.join(os.homedir(), '.config');
77
+ return path.join(base, 'agentic-security');
78
+ }
79
+
80
+ // Credential locations under the user's home that no target has a reason to
81
+ // read. A denylist can never be complete, so it is the floor, not the claim:
82
+ // the caller adds sealed-label and evidence directories explicitly.
83
+ const HOME_CREDENTIAL_PATHS = Object.freeze([
84
+ '.ssh', '.aws', '.gnupg', '.azure', '.kube', '.docker', '.netrc', '.npmrc',
85
+ '.pypirc', '.git-credentials', '.claude', '.config/gcloud', '.config/gh',
86
+ ]);
87
+
88
+ /**
89
+ * Every host path a target process must not read. The signing/HMAC key
90
+ * directory is always included.
91
+ */
92
+ export function protectedReadPaths({
93
+ home = os.homedir(), env = process.env, labelDirs = [], evidenceDirs = [], extra = [],
94
+ } = {}) {
95
+ const out = [keyDirectory(env)];
96
+ for (const rel of HOME_CREDENTIAL_PATHS) out.push(path.join(home, rel));
97
+ for (const p of [...labelDirs, ...evidenceDirs, ...extra]) if (p) out.push(path.resolve(p));
98
+ return [...new Set(out)];
99
+ }
100
+
101
+ // ---- secret material in worker environments --------------------------------
102
+
103
+ const SECRET_NAME = /(?:^|_)(?:secret|token|password|passwd|api_?key|private_?key|credential|hmac|signing|auth)(?:_|$)|^(?:AWS_|GITHUB_|NPM_|ANTHROPIC_|OPENAI_)/i;
104
+ const PEM_PRIVATE = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
105
+ // An explicit allowlist wins over the name heuristic: these carry no secret.
106
+ const SAFE_NAMES = new Set(['PATH', 'HOME', 'TMPDIR', 'LANG', 'ROOT', 'LC_ALL', 'TZ', 'CI']);
107
+
108
+ /** Names (never values) of environment entries that look like secret material. */
109
+ export function secretEnvNames(env = {}) {
110
+ const out = [];
111
+ for (const [k, v] of Object.entries(env || {})) {
112
+ if (SAFE_NAMES.has(k)) continue;
113
+ if (SECRET_NAME.test(k) || PEM_PRIVATE.test(String(v ?? ''))) out.push(k);
114
+ }
115
+ return out.sort();
116
+ }
117
+
118
+ /** `{ ok:false, offenders }` when an environment bound for a worker holds secrets. */
119
+ export function assertNoSecretMaterial(env = {}) {
120
+ const offenders = secretEnvNames(env);
121
+ return { ok: offenders.length === 0, offenders };
122
+ }
123
+
124
+ /** Copy of `env` without secret-looking entries. */
125
+ export function scrubEnv(env = {}) {
126
+ const drop = new Set(secretEnvNames(env));
127
+ return Object.fromEntries(Object.entries(env || {}).filter(([k]) => !drop.has(k)));
128
+ }
129
+
130
+ // ---- verification status ---------------------------------------------------
131
+
132
+ // Mirrors the PRD state model. Only this module decides how a status is formed
133
+ // from a boundary run, and it never reads target output.
134
+ export const VERIFICATION_STATUSES = Object.freeze(
135
+ ['not-run', 'unsupported', 'inconclusive', 'error', 'refuted', 'confirmed'],
136
+ );
137
+
138
+ /**
139
+ * Status from what the BOUNDARY observed (did it run, did it error). A run on
140
+ * its own can never confirm anything: that takes verifier-observed evidence
141
+ * (`settleVerification`).
142
+ */
143
+ export function classifyRun(run) {
144
+ if (!run || run.executed !== true) return 'not-run';
145
+ if (run.status === 'error') return 'error';
146
+ return 'inconclusive';
147
+ }
148
+
149
+ /**
150
+ * Settle a verification status. `evidence` must come from verifier-domain code
151
+ * (`observedBy: 'verifier'`) that examined state the target cannot write (for
152
+ * example by re-running the oracle outside the target's reach). Target stdout,
153
+ * stderr and workspace files are NOT evidence. `confirmed` is only reachable
154
+ * from an executed run plus satisfied verifier evidence; `refuted` needs the
155
+ * oracle to declare its preconditions held.
156
+ */
157
+ export function settleVerification(run, evidence) {
158
+ const base = classifyRun(run);
159
+ if (base !== 'inconclusive') return base;
160
+ if (!evidence || evidence.observedBy !== DOMAINS.VERIFIER) return base;
161
+ if (evidence.satisfied === true) return 'confirmed';
162
+ if (evidence.satisfied === false && evidence.preconditionsHeld === true) return 'refuted';
163
+ return base;
164
+ }
165
+
166
+ const STATUS_LIKE = /\b(?:verified|confirmed|execution-proven|status\s*[:=])\b/i;
167
+
168
+ /** Count of target-output lines that merely LOOK like a status claim (informational). */
169
+ export function countStatusLikeLines(text) {
170
+ return String(text || '').split('\n').filter((l) => STATUS_LIKE.test(l)).length;
171
+ }
@@ -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
- re: /^env\s*:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gm,
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
- const re = new RegExp(p.re.source, p.re.flags.includes('g') ? p.re.flags : p.re.flags + 'g');
109
- let m;
110
- while ((m = re.exec(raw))) {
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)}`;