@clear-capabilities/agentic-security-scanner 0.128.1 → 0.132.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 (87) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/bin/agentic-security.js +52 -2
  3. package/dist/11.index.js +2 -2
  4. package/dist/113.index.js +498 -7
  5. package/dist/178.index.js +1 -1
  6. package/dist/207.index.js +220 -0
  7. package/dist/238.index.js +218 -0
  8. package/dist/259.index.js +975 -0
  9. package/dist/384.index.js +1 -1
  10. package/dist/415.index.js +1 -1
  11. package/dist/435.index.js +4 -4
  12. package/dist/526.index.js +844 -0
  13. package/dist/637.index.js +1 -1
  14. package/dist/830.index.js +1 -1
  15. package/dist/agentic-security.mjs +106 -194
  16. package/dist/agentic-security.mjs.sha256 +1 -1
  17. package/package.json +33 -17
  18. package/src/dataflow/CLAUDE.md +4 -1
  19. package/src/dataflow/async-sequencing.js +8 -3
  20. package/src/dataflow/catalog.js +278 -11
  21. package/src/dataflow/cross-repo.js +1 -1
  22. package/src/dataflow/cross-service-taint.js +1 -1
  23. package/src/dataflow/engine.js +182 -61
  24. package/src/dataflow/ifds.js +10 -5
  25. package/src/dataflow/index.js +15 -3
  26. package/src/dataflow/points-to.js +8 -2
  27. package/src/dataflow/proof-gate.js +7 -0
  28. package/src/dataflow/sanitizer-gate.js +89 -0
  29. package/src/dataflow/tabulation.js +14 -3
  30. package/src/engine.js +170 -7
  31. package/src/integrations/index.js +1 -1
  32. package/src/ir/CLAUDE.md +49 -4
  33. package/src/ir/call-sites.js +66 -0
  34. package/src/ir/callgraph.js +174 -7
  35. package/src/ir/class-hierarchy.js +22 -2
  36. package/src/ir/index.js +138 -51
  37. package/src/ir/ir-stats.js +126 -0
  38. package/src/ir/parser-cpp.js +829 -0
  39. package/src/ir/parser-cs.js +4 -1
  40. package/src/ir/parser-go.js +4 -1
  41. package/src/ir/parser-js.js +13 -1
  42. package/src/ir/parser-kt.js +4 -1
  43. package/src/ir/parser-php.js +10 -3
  44. package/src/ir/parser-py-cst.js +62 -10
  45. package/src/ir/tree-sitter-loader.js +13 -1
  46. package/src/llm-validator/index.js +9 -2
  47. package/src/llm-validator/redact.js +157 -0
  48. package/src/mcp/tools.js +2 -2
  49. package/src/posture/CLAUDE.md +193 -1
  50. package/src/posture/accuracy-scorecard.js +317 -0
  51. package/src/posture/api-contract.js +1 -1
  52. package/src/posture/attestation.js +202 -0
  53. package/src/posture/auditor-walkthrough.js +12 -3
  54. package/src/posture/compliance-policy.js +1 -1
  55. package/src/posture/corpus-enroll.js +303 -0
  56. package/src/posture/corpus-match.js +52 -0
  57. package/src/posture/cross-lang-openapi.js +1 -1
  58. package/src/posture/custom-rules.js +3 -3
  59. package/src/posture/execution-proof.js +92 -0
  60. package/src/posture/exploitability-probability.js +1 -1
  61. package/src/posture/falsification.js +45 -1
  62. package/src/posture/fix-metrics.js +197 -0
  63. package/src/posture/fix-verify.js +129 -2
  64. package/src/posture/license-policy.js +1 -1
  65. package/src/posture/profile.js +1 -1
  66. package/src/posture/proof-tier.js +33 -0
  67. package/src/posture/relevance.js +379 -0
  68. package/src/posture/root-cause-sweep.js +0 -0
  69. package/src/posture/rule-overrides.js +1 -1
  70. package/src/posture/sca-policy.js +1 -1
  71. package/src/posture/scan-checkpoint.js +277 -0
  72. package/src/posture/suppressions.js +1 -1
  73. package/src/posture/test-runner.js +147 -0
  74. package/src/posture/verification-separation.js +131 -0
  75. package/src/report/index.js +11 -0
  76. package/src/runScan.js +5 -7
  77. package/src/sandbox/CLAUDE.md +340 -0
  78. package/src/sandbox/backend-disabled.js +14 -0
  79. package/src/sandbox/backend-namespace.js +335 -0
  80. package/src/sandbox/backend-userspace.js +83 -0
  81. package/src/sandbox/capabilities.js +181 -0
  82. package/src/sandbox/index.js +30 -0
  83. package/src/sandbox/limits.js +63 -0
  84. package/src/sandbox/result.js +104 -0
  85. package/src/sca/dep-confusion.js +1 -1
  86. package/src/util/glob.js +173 -0
  87. package/src/util/yaml.js +24 -0
@@ -7,7 +7,7 @@
7
7
 
8
8
  import * as fs from 'node:fs';
9
9
  import * as path from 'node:path';
10
- import * as yaml from 'js-yaml';
10
+ import * as yaml from '../util/yaml.js';
11
11
  import { statePath, safeWriteState } from './state-dir.js';
12
12
 
13
13
  const MS_PER_DAY = 86400000;
@@ -0,0 +1,147 @@
1
+ // R5 (partial, roadmap) — the project's own test suite as a verification
2
+ // stage for `verifyFix()` (see `fix-verify.js`). Closes the gap where a
3
+ // "verified" fix only proved a finding's stableId stopped firing — a patch
4
+ // that deletes the feature entirely would satisfy that just as well as a
5
+ // real fix. Running the project's own tests is the cheapest available check
6
+ // that the application still works.
7
+ //
8
+ // Execution-safety note: this spawns the TARGET PROJECT's own test command
9
+ // in the target project's own directory. That is deliberately NOT routed
10
+ // through the R1 confinement sandbox (`../sandbox/`). That sandbox exists to
11
+ // contain untrusted proof-of-concept exploit code the scanner itself
12
+ // synthesizes — code nobody has vetted, being run for the first time. A
13
+ // project's pre-existing test suite is the opposite case: it is the
14
+ // project's own trusted source, already sitting on disk, and running it is
15
+ // exactly what a human developer does by hand before trusting a fix.
16
+ // Wrapping "npm test" / "pytest" / "go test" in the PoC sandbox's
17
+ // syscall/network/filesystem restrictions would break the large majority of
18
+ // real test suites (they bind local ports, spawn child processes, write temp
19
+ // fixtures, etc.) for no corresponding security benefit.
20
+
21
+ import { spawnSync } from 'node:child_process';
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+
25
+ const DEFAULT_TIMEOUT_MS = 300_000;
26
+ const NPM_PLACEHOLDER = /Error: no test specified/i;
27
+
28
+ function _exists(scanRoot, rel) {
29
+ try { return fs.existsSync(path.join(scanRoot, rel)); } catch { return false; }
30
+ }
31
+
32
+ function _isDir(scanRoot, rel) {
33
+ try { return fs.statSync(path.join(scanRoot, rel)).isDirectory(); } catch { return false; }
34
+ }
35
+
36
+ function _binaryAvailable(cmd) {
37
+ try {
38
+ const r = spawnSync(cmd, ['--version'], { timeout: 5_000, stdio: 'ignore' });
39
+ return !(r.error && r.error.code === 'ENOENT');
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // Detect the project's test command. Read-only — never spawns the actual
46
+ // test run, only (optionally) a cheap `--version` probe to confirm a tool
47
+ // like `pytest` is actually installed before committing to it. Returns
48
+ // `null` when nothing detectable is found — most scanned repos will hit
49
+ // this path, and that must not be treated as a failure by callers.
50
+ export function detectTestCommand(scanRoot) {
51
+ if (!scanRoot) return null;
52
+
53
+ // JS/TS — package.json with a real (non-placeholder) `scripts.test`.
54
+ let pkg = null;
55
+ try { pkg = JSON.parse(fs.readFileSync(path.join(scanRoot, 'package.json'), 'utf8')); } catch { pkg = null; }
56
+ const testScript = pkg && pkg.scripts && pkg.scripts.test;
57
+ if (testScript && !NPM_PLACEHOLDER.test(String(testScript))) {
58
+ if (_exists(scanRoot, 'pnpm-lock.yaml')) return { cmd: 'pnpm', args: ['test'], kind: 'pnpm' };
59
+ if (_exists(scanRoot, 'yarn.lock')) return { cmd: 'yarn', args: ['test'], kind: 'yarn' };
60
+ if (_exists(scanRoot, 'bun.lockb') || _exists(scanRoot, 'bun.lock')) return { cmd: 'bun', args: ['test'], kind: 'bun' };
61
+ return { cmd: 'npm', args: ['test', '--silent'], kind: 'npm' };
62
+ }
63
+
64
+ // Python — pytest.ini / pyproject.toml / tox.ini / a tests/ dir, and the
65
+ // `pytest` binary actually available. If pytest isn't installed we do NOT
66
+ // report a python test command — falling through lets a later language
67
+ // marker (e.g. go.mod in a polyglot repo) still be detected.
68
+ const pyMarker = _exists(scanRoot, 'pytest.ini') || _exists(scanRoot, 'pyproject.toml') ||
69
+ _exists(scanRoot, 'tox.ini') || _isDir(scanRoot, 'tests');
70
+ if (pyMarker && _binaryAvailable('pytest')) {
71
+ return { cmd: 'pytest', args: ['-q'], kind: 'pytest' };
72
+ }
73
+
74
+ // Go
75
+ if (_exists(scanRoot, 'go.mod')) {
76
+ return { cmd: 'go', args: ['test', './...'], kind: 'go' };
77
+ }
78
+
79
+ return null;
80
+ }
81
+
82
+ // Run the detected test command with a walltime budget. Always returns a
83
+ // result object — never throws. Distinguishes four outcomes:
84
+ // - no detectable/runnable command -> status: 'skipped' (does NOT fail)
85
+ // - ran and exited 0 -> status: 'passed'
86
+ // - ran and exited non-zero -> status: 'failed'
87
+ // - ran past the timeout budget -> status: 'failed', timedOut: true
88
+ export function runProjectTests(scanRoot, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
89
+ const startedAt = Date.now();
90
+ const command = detectTestCommand(scanRoot);
91
+ if (!command) {
92
+ return {
93
+ status: 'skipped', passed: null, skipped: true,
94
+ reason: 'no-test-command-detected', exitCode: null, timedOut: false,
95
+ durationMs: Date.now() - startedAt,
96
+ };
97
+ }
98
+
99
+ let r;
100
+ try {
101
+ r = spawnSync(command.cmd, command.args, {
102
+ cwd: scanRoot,
103
+ encoding: 'utf8',
104
+ timeout: timeoutMs,
105
+ env: { ...process.env, CI: '1' },
106
+ });
107
+ } catch (e) {
108
+ // The spawn call itself threw (rare — e.g. cwd vanished). Treat as
109
+ // "could not run", not "ran and failed".
110
+ return {
111
+ status: 'skipped', passed: null, skipped: true,
112
+ reason: `spawn-error: ${e.message}`, exitCode: null, timedOut: false,
113
+ durationMs: Date.now() - startedAt,
114
+ };
115
+ }
116
+ const durationMs = Date.now() - startedAt;
117
+
118
+ if (r.error && r.error.code === 'ENOENT') {
119
+ // The detected tool isn't actually installed on this machine. Not a
120
+ // test failure — the suite never ran.
121
+ return {
122
+ status: 'skipped', passed: null, skipped: true,
123
+ reason: `${command.kind}-not-installed`, exitCode: null, timedOut: false, durationMs,
124
+ };
125
+ }
126
+
127
+ if (r.status === null) {
128
+ // spawnSync sets status:null both on timeout-kill and on being killed by
129
+ // another signal; either way the run did not complete, which is a
130
+ // verification failure, never a skip — we asked for a result and the
131
+ // process was terminated before producing one.
132
+ return {
133
+ status: 'failed', passed: false, skipped: false,
134
+ reason: 'timed-out', exitCode: null, timedOut: true, durationMs,
135
+ };
136
+ }
137
+
138
+ return {
139
+ status: r.status === 0 ? 'passed' : 'failed',
140
+ passed: r.status === 0,
141
+ skipped: false,
142
+ reason: r.status === 0 ? null : 'test-failures',
143
+ exitCode: r.status,
144
+ timedOut: false,
145
+ durationMs,
146
+ };
147
+ }
@@ -0,0 +1,131 @@
1
+ // R7 — adversarial verification with ENFORCED SEPARATION.
2
+ //
3
+ // The falsification pass (`falsification.js`) already tries to DISPROVE a
4
+ // finding. What it could not previously do is *prove* that whoever checked the
5
+ // finding was not whoever produced it. That guarantee is what this module adds,
6
+ // plus a recorded multi-perspective verdict for contested findings.
7
+ //
8
+ // The property that matters: a verifier is structurally unable to rubber-stamp
9
+ // its own finding.
10
+ // - `recordProducer` stamps who produced the finding, WRITE-ONCE. A later
11
+ // party cannot re-stamp itself as the producer to manufacture separation.
12
+ // - `assertSeparation` refuses when the verifier id equals the recorded
13
+ // producer id, and FAILS CLOSED when no producer was recorded at all
14
+ // (separation that cannot be established is not separation).
15
+ // - `recordVerdict` runs that check itself, so there is no code path that
16
+ // records a verdict without it. One verifier gets one vote per lens: a
17
+ // re-vote replaces the previous one rather than stuffing the ballot.
18
+ //
19
+ // RECALL-PRESERVING, same precedent as `falsification.js` and
20
+ // `dataflow/proof-gate.js`: nothing here ever removes a finding and nothing
21
+ // here ever touches `severity`. A `refuted` consensus is a triage signal, not
22
+ // a deletion — absence of proof is not proof of absence.
23
+ //
24
+ // NO THROWING (posture/CLAUDE.md convention): every entry point returns a
25
+ // refusal object `{ ok:false, refused:true, reason }` instead of throwing, so
26
+ // an annotator can call it inside the engine's pipeline without a guard.
27
+
28
+ export const VERIFICATION_VERDICTS = ['upheld', 'refuted', 'undecided'];
29
+
30
+ // Verifier ids are namespaced so they can never collide with a producer id.
31
+ export const VERIFIER_FALSIFICATION = 'verifier:falsification';
32
+ export const VERIFIER_LLM_REVIEW = 'verifier:llm-review';
33
+
34
+ function _refuse(reason) { return { ok: false, refused: true, reason }; }
35
+
36
+ /** Namespaced id for whoever produced a finding — derived from its detector. */
37
+ export function producerIdOf(finding) {
38
+ const p = finding && finding.parser ? String(finding.parser) : 'unknown';
39
+ return `detector:${p}`;
40
+ }
41
+
42
+ /**
43
+ * Stamp who produced this finding. Write-once: a second call with a different
44
+ * id is REFUSED (this is what stops a verifier reassigning provenance to
45
+ * itself). A repeat call with the same id is a no-op success.
46
+ */
47
+ export function recordProducer(finding, producerId) {
48
+ if (!finding || typeof finding !== 'object') return _refuse('no finding');
49
+ const id = producerId ? String(producerId) : '';
50
+ if (!id) return _refuse('no producer id');
51
+ if (!finding.verification || typeof finding.verification !== 'object') {
52
+ finding.verification = { producer: id, verdicts: [] };
53
+ return { ok: true, producer: id };
54
+ }
55
+ if (!finding.verification.producer) {
56
+ finding.verification.producer = id;
57
+ if (!Array.isArray(finding.verification.verdicts)) finding.verification.verdicts = [];
58
+ return { ok: true, producer: id };
59
+ }
60
+ if (finding.verification.producer === id) return { ok: true, producer: id };
61
+ return _refuse(`producer already recorded as "${finding.verification.producer}" — write-once`);
62
+ }
63
+
64
+ /**
65
+ * The separation check. `{ ok:true, producer }` when the verifier is a party
66
+ * other than the producer; a refusal otherwise. Fails closed when no producer
67
+ * has been recorded.
68
+ */
69
+ export function assertSeparation(finding, verifierId) {
70
+ if (!finding || typeof finding !== 'object') return _refuse('no finding');
71
+ const vid = verifierId ? String(verifierId) : '';
72
+ if (!vid) return _refuse('no verifier id');
73
+ const producer = finding.verification && finding.verification.producer;
74
+ if (!producer) {
75
+ return _refuse('no producer recorded — separation cannot be established');
76
+ }
77
+ if (producer === vid) {
78
+ return _refuse(`verifier "${vid}" is the producer of this finding — separation violated`);
79
+ }
80
+ return { ok: true, producer };
81
+ }
82
+
83
+ /**
84
+ * Record one verifier's verdict from one perspective (`lens`, e.g.
85
+ * 'reachability' | 'control-flow' | 'data-shape'). Refuses — recording
86
+ * nothing — when separation fails or the verdict is not one of
87
+ * VERIFICATION_VERDICTS. Never touches severity, never removes anything.
88
+ */
89
+ export function recordVerdict(finding, { verifierId, lens, verdict, reason } = {}) {
90
+ if (!finding || typeof finding !== 'object') return _refuse('no finding');
91
+ if (!lens) return _refuse('no lens');
92
+ if (!VERIFICATION_VERDICTS.includes(verdict)) {
93
+ return _refuse(`unknown verdict "${verdict}" — expected one of ${VERIFICATION_VERDICTS.join('|')}`);
94
+ }
95
+ const sep = assertSeparation(finding, verifierId);
96
+ if (!sep.ok) return sep;
97
+
98
+ const entry = {
99
+ verifierId: String(verifierId),
100
+ lens: String(lens),
101
+ verdict,
102
+ ...(reason ? { reason: String(reason) } : {}),
103
+ };
104
+ const list = finding.verification.verdicts;
105
+ const i = list.findIndex(v => v.verifierId === entry.verifierId && v.lens === entry.lens);
106
+ if (i >= 0) list[i] = entry; else list.push(entry);
107
+ return { ok: true, recorded: entry };
108
+ }
109
+
110
+ /**
111
+ * Majority across every recorded verdict. Ties — including "no verdicts at
112
+ * all" — are 'undecided'. `lenses` is the sorted set of perspectives that
113
+ * actually voted, so a caller can see whether a verdict is one-eyed or a panel.
114
+ */
115
+ export function consensusOf(finding) {
116
+ const list = (finding && finding.verification && Array.isArray(finding.verification.verdicts))
117
+ ? finding.verification.verdicts : [];
118
+ let upheld = 0, refuted = 0, undecided = 0;
119
+ const lenses = new Set();
120
+ for (const v of list) {
121
+ if (!v) continue;
122
+ if (v.lens) lenses.add(String(v.lens));
123
+ if (v.verdict === 'upheld') upheld++;
124
+ else if (v.verdict === 'refuted') refuted++;
125
+ else if (v.verdict === 'undecided') undecided++;
126
+ }
127
+ let verdict = 'undecided';
128
+ if (upheld > refuted) verdict = 'upheld';
129
+ else if (refuted > upheld) verdict = 'refuted';
130
+ return { verdict, upheld, refuted, undecided, lenses: [...lenses].sort() };
131
+ }
@@ -180,6 +180,11 @@ export function normalizeFindings(scan){
180
180
  paramKeyConfidence: f.poc.paramKeyConfidence || null,
181
181
  paramKeyInferred: typeof f.poc.paramKeyInferred === 'boolean' ? f.poc.paramKeyInferred : null,
182
182
  } : null,
183
+ // R2: execution-proof tier — copied through only when the annotator
184
+ // (posture/proof-tier.js) actually attached it. Never synthesised here;
185
+ // a finding with no proof backing simply omits these fields.
186
+ ...(f.proofTier !== undefined ? { proofTier: f.proofTier } : {}),
187
+ ...(f.proofEvidence !== undefined ? { proofEvidence: f.proofEvidence } : {}),
183
188
  // Phase-1 next-gen P1.3 (FR-UX-1, FR-UX-2): calibrated probability +
184
189
  // 95% Wilson CI + sample size. Null when N < MIN_SAMPLES_FOR_CALIBRATION
185
190
  // for this family; `calibration_reason` explains why.
@@ -401,6 +406,12 @@ export function toJSON(scan, meta={}, opts={}){
401
406
  // threw and were skipped. The findings still ship; downstream consumers
402
407
  // see the gap.
403
408
  annotatorErrors: Array.isArray(scan.annotatorErrors) ? scan.annotatorErrors : [],
409
+ // R4 — run attestation: a stable, order-independent digest over this
410
+ // finding set bound to the engine/ruleset/bundle that produced it.
411
+ // Attached by the CLI (posture/attestation.js); null when not computed.
412
+ // It carries its own `proves` / `doesNotProve` statement — do not quote
413
+ // the digest as cross-machine reproducibility, which it is not.
414
+ attestation: scan.attestation || null,
404
415
  _scanMeta: scan._scanMeta || null,
405
416
  };
406
417
  if (opts.includeSuppressed) out.suppressed = scan.suppressions||[];
package/src/runScan.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import * as fs from 'node:fs/promises';
4
4
  import * as path from 'node:path';
5
5
  import * as cp from 'node:child_process';
6
- import fg from 'fast-glob';
6
+ import { listFiles } from './util/glob.js';
7
7
  import { runFullScan, shouldScan } from './engine.js';
8
8
  import { appendScanSnapshot } from './posture/security-trend.js';
9
9
  import { recover as recoverFixHistory } from './posture/fix-history.js';
@@ -26,11 +26,7 @@ const DEFAULT_IGNORE = [
26
26
  ];
27
27
 
28
28
  export async function readTree(root, { ignore = [] } = {}) {
29
- const entries = await fg('**/*', {
30
- cwd: root, dot: true, onlyFiles: true,
31
- ignore: [...DEFAULT_IGNORE, ...ignore], followSymbolicLinks: false,
32
- suppressErrors: true,
33
- });
29
+ const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
34
30
  const fileContents = {};
35
31
  const depFileContents = {};
36
32
  for (const rel of entries) {
@@ -122,7 +118,9 @@ export async function runScan(rootDir, opts = {}) {
122
118
  }
123
119
  }
124
120
 
125
- const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root }, opts.onProgress || (()=>{}));
121
+ // R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
122
+ // AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
123
+ const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume }, opts.onProgress || (()=>{}));
126
124
  // Premortem 2R4.2: stamp ruleset version + source on the scan result, and
127
125
  // notify if the operator pinned a different version than what's installed.
128
126
  try { stampScan(root, scan); } catch {}