@clear-capabilities/agentic-security-scanner 0.154.0 → 0.156.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.
@@ -19,14 +19,14 @@ __webpack_require__.d(__webpack_exports__, {
19
19
 
20
20
  // EXTERNAL MODULE: external "node:fs"
21
21
  var external_node_fs_ = __webpack_require__(73024);
22
- // EXTERNAL MODULE: ./src/posture/state-dir.js
23
- var state_dir = __webpack_require__(31174);
22
+ // EXTERNAL MODULE: external "node:crypto"
23
+ var external_node_crypto_ = __webpack_require__(77598);
24
+ // EXTERNAL MODULE: ./src/language/trusted-inputs.js
25
+ var trusted_inputs = __webpack_require__(6342);
24
26
  // EXTERNAL MODULE: ./src/language/haskell-manifests.js
25
27
  var haskell_manifests = __webpack_require__(16522);
26
28
  // EXTERNAL MODULE: ./src/language/haskell-resolved-graph.js
27
29
  var haskell_resolved_graph = __webpack_require__(86359);
28
- // EXTERNAL MODULE: external "node:crypto"
29
- var external_node_crypto_ = __webpack_require__(77598);
30
30
  ;// CONCATENATED MODULE: ./src/language/nix-closure.js
31
31
  // Resolved derivation and closure inventory (NIX-008).
32
32
  //
@@ -1032,6 +1032,7 @@ function mergeEvaluationHealth(scanHealth, result) {
1032
1032
 
1033
1033
 
1034
1034
 
1035
+
1035
1036
  const RESOLVED_PASS_VERSION = 'resolved-pass/1';
1036
1037
 
1037
1038
  const PLAN = /(?:^|\/)dist-newstyle\/cache\/plan\.json$/;
@@ -1071,6 +1072,12 @@ function resolvedHackageComponents(files) {
1071
1072
  const gaps = (g.gaps || []).map((x) => ({ kind: `resolved-${x.kind}`, detail: x.detail || x.message || x.kind, file: r.file }));
1072
1073
  const summary = { file: r.file, source: g.source, graphAvailable: g.graphAvailable, freshness: g.freshness, closure: g.closure, units: g.units ? g.units.length : null, edges: g.edges ? g.edges.length : null };
1073
1074
  if (!g.graphAvailable || g.freshness.status === 'stale') return { components: [], gaps, summary };
1075
+ // The plan is a file in the scanned project. Where the project's own freeze file also pins a package, the two must agree: a plan that
1076
+ // contradicts the freeze is stale or forged, and contributes no versions.
1077
+ const frozen = new Map(((r.manifests && r.manifests.lockedPackages) || []).filter((l) => l.name && l.version).map((l) => [l.name, l.version]));
1078
+ const clashes = (g.units || []).filter((u) => u.name && u.version && frozen.has(u.name) && frozen.get(u.name) !== u.version).map((u) => `${u.name} ${u.version} (plan) vs ${frozen.get(u.name)} (freeze)`);
1079
+ if (clashes.length) { gaps.push({ kind: 'resolved-stale-plan-contradicts-freeze', detail: `the plan contradicts the freeze file (${clashes.slice(0, 3).join('; ')}): it is stale or forged, so no version was taken from it`, file: r.file }); return { components: [], gaps, summary }; }
1080
+ gaps.push({ kind: 'resolved-project-supplied-plan', detail: 'the plan is a file in the scanned project: it is checked against the declared bounds and the freeze file, but whoever controls the project controls its contents', file: r.file });
1074
1081
  const SCOPE = { runtime: 'required', test: 'optional', benchmark: 'optional', setup: 'optional', 'build-tool': 'optional' };
1075
1082
  const direct = new Set((g.declared || []).map((d) => d.name));
1076
1083
  const components = [];
@@ -1084,24 +1091,39 @@ function resolvedHackageComponents(files) {
1084
1091
  }
1085
1092
 
1086
1093
  // ── Nix closure ──────────────────────────────────────────────────────────────────
1087
- function loadNixExports(files) {
1088
- const exports = []; const sources = []; const problems = []; let expected = {};
1089
- for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) {
1090
- const parsed = parseJson(files[p]);
1091
- sources.push(p);
1092
- if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: p, detail: parsed.reason }); continue; }
1094
+ function loadNixExports(files, env = process.env) {
1095
+ const exports = []; const sources = []; const problems = []; const trust = new Set();
1096
+ const take = (label, text, origin) => {
1097
+ sources.push(label);
1098
+ const parsed = parseJson(text);
1099
+ if (!parsed.ok) { problems.push({ kind: 'malformed-export', file: label, detail: parsed.reason }); return; }
1093
1100
  const j = parsed.value;
1094
- if (j && Array.isArray(j.exports)) { exports.push(...j.exports.filter((e) => e && typeof e === 'object')); if (j.expected && typeof j.expected === 'object') expected = { ...expected, ...j.expected }; }
1101
+ trust.add(origin);
1102
+ // NOTE: an `expected` block inside the file is IGNORED. The file is the thing under test, so it cannot also say what it should be
1103
+ // checked against; what an export must match is derived by the scanner (below) or comes from the operator.
1104
+ if (j && Array.isArray(j.exports)) exports.push(...j.exports.filter((e) => e && typeof e === 'object'));
1095
1105
  else if (j && typeof j === 'object' && j.schema && (j.data !== undefined || j.text !== undefined)) exports.push(j);
1096
- else exports.push({ text: files[p] });
1106
+ else exports.push({ text });
1107
+ };
1108
+ if (env.AGENTIC_SECURITY_NIX_EXPORT) {
1109
+ try { take(env.AGENTIC_SECURITY_NIX_EXPORT, (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT, 'utf8'), 'operator'); }
1110
+ catch (e) { sources.push(env.AGENTIC_SECURITY_NIX_EXPORT); problems.push({ kind: 'malformed-export', file: env.AGENTIC_SECURITY_NIX_EXPORT, detail: `unreadable: ${e.code || e.message}` }); }
1097
1111
  }
1098
- return { exports, sources, problems, expected };
1112
+ for (const p of Object.keys(files).filter((x) => NIX_EXPORT.test(x)).sort()) take(p, files[p], 'project-supplied');
1113
+ // What the export must have been made against: the real flake.lock of this project (a forged export cannot also forge that file's hash
1114
+ // without the lock it claims to describe being the lock that is here).
1115
+ const expected = {};
1116
+ const lockPath = Object.keys(files).filter((p) => /(^|\/)flake\.lock$/.test(p)).sort((a, b) => a.length - b.length)[0];
1117
+ if (lockPath) expected.flakeLockSha256 = (0,external_node_crypto_.createHash)('sha256').update(files[lockPath]).digest('hex');
1118
+ else if (sources.length) problems.push({ kind: 'export-unverified', detail: 'there is no flake.lock to bind the export to, so it cannot be shown to describe this project' });
1119
+ if (env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY) { try { expected.publicKeyPem = (0,external_node_fs_.readFileSync)(env.AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, 'utf8'); expected.requireSigned = true; } catch { problems.push({ kind: 'malformed-export', detail: 'the configured export public key is unreadable' }); } }
1120
+ return { exports, sources, problems, expected, trust: [...trust] };
1099
1121
  }
1100
1122
 
1101
1123
  /** The imported closure (and each derivation's env block) for a file set, or null when there is no export. Cached per file set. */
1102
1124
  function nixClosureOf(files, { now = Date.now() } = {}) {
1103
1125
  return memoized(files, 'nix-closure', () => {
1104
- const { exports, sources, problems, expected } = loadNixExports(files);
1126
+ const { exports, sources, problems, expected, trust } = loadNixExports(files);
1105
1127
  if (!sources.length) return null;
1106
1128
  const closure = importNixClosure({ exports, expected, now });
1107
1129
  const drvEnv = {};
@@ -1110,15 +1132,19 @@ function nixClosureOf(files, { now = Date.now() } = {}) {
1110
1132
  let data = ex.data; if (data === undefined && typeof ex.text === 'string') { const p = parseJson(ex.text); data = p.ok ? p.value : null; }
1111
1133
  for (const [k, v] of Object.entries(data || {})) if (v && v.env) drvEnv[k] = v.env;
1112
1134
  }
1113
- return { closure, drvEnv, sources, problems };
1135
+ // A disclosure that says the export does not describe THIS project (another lock, another target, a bad or missing required
1136
+ // signature) means its contents are not evidence about this build: it is refused, not merely labelled.
1137
+ const REFUSE = new Set(['stale-export', 'foreign-target', 'foreign-revision', 'invalid-signature', 'unsigned']);
1138
+ const refused = (closure.disclosures || []).filter((d) => REFUSE.has(d.kind));
1139
+ return { closure, drvEnv, sources, problems, trust, refused };
1114
1140
  });
1115
1141
  }
1116
1142
 
1117
1143
  /** Advisory records for Nix: a snapshot named by AGENTIC_SECURITY_NIX_ADVISORIES or kept in the project state directory. */
1118
1144
  function configuredNixAdvisories(root, env = process.env, hackageDb = null) {
1119
- const path = env.AGENTIC_SECURITY_NIX_ADVISORIES || (root && (0,external_node_fs_.existsSync)((0,state_dir.statePath)(root, 'nix-advisories.json')) ? (0,state_dir.statePath)(root, 'nix-advisories.json') : null);
1120
- if (!path) return { data: null, reason: 'no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES or provide nix-advisories.json in the project state directory)' };
1121
- let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
1145
+ const sel = (0,trusted_inputs/* resolveOperatorSnapshot */.Rn)({ envVar: 'AGENTIC_SECURITY_NIX_ADVISORIES', fileName: 'nix-advisories.json', root, env });
1146
+ if (!sel.path) return { data: null, reason: `no Nix advisory snapshot is configured (set AGENTIC_SECURITY_NIX_ADVISORIES, or place nix-advisories.json in the operator configuration directory, agentic-security under XDG_CONFIG_HOME)${sel.projectLocalIgnored ? (0,trusted_inputs/* IGNORED_NOTE */.cY)('nix-advisories.json') : ''}` };
1147
+ let snap; try { snap = JSON.parse((0,external_node_fs_.readFileSync)(sel.path, 'utf8')); } catch (e) { return { data: null, reason: `the Nix advisory snapshot is unreadable: ${e.code || e.message}` }; }
1122
1148
  const records = Array.isArray(snap) ? snap : (Array.isArray(snap.records) ? snap.records : []);
1123
1149
  return { data: new NixAdvisoryData({ records, hackage: hackageDb, source: 'pinned-snapshot', generatedAt: (snap && snap.generatedAt) || null }), reason: null };
1124
1150
  }
@@ -1134,9 +1160,11 @@ function _analyzeNixClosure(files, { scanRoot = null, env = process.env, now = D
1134
1160
  const adv = configuredNixAdvisories(scanRoot, env, hackage);
1135
1161
  let usage = null; try { usage = (0,haskell_supply/* collectUsage */.c$)(files); } catch { usage = null; }
1136
1162
  let matched = null;
1137
- try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
1163
+ if (!c.refused.length) try { matched = matchNixVulnerabilities({ closure: c.closure, drvEnv: c.drvEnv, data: adv.data, overlays: overlayEvidence(files), haskellUsage: usage }); } catch (e) { matched = null; c.problems.push({ kind: 'match-failed', detail: String((e && e.message) || e).slice(0, 160) }); }
1138
1164
  const gaps = [];
1165
+ if (c.trust.includes('project-supplied') && !c.trust.includes('operator')) gaps.push({ kind: 'closure-project-supplied', detail: 'the closure export is a file in the scanned project, bound to its flake.lock but not signed: whoever controls the project controls its contents, so the ABSENCE of a finding for a component it lists is not evidence the real build is free of it (provide the export through AGENTIC_SECURITY_NIX_EXPORT, signed with AGENTIC_SECURITY_NIX_EXPORT_PUBKEY, to have it treated as operator evidence)' });
1139
1166
  for (const d of c.closure.disclosures || []) gaps.push({ kind: `closure-${d.kind}`, detail: d.detail });
1167
+ for (const d of c.refused) gaps.push({ kind: `closure-refused-${d.kind}`, detail: `${d.detail}; the export was REFUSED and contributes nothing` });
1140
1168
  for (const p of c.problems) gaps.push({ kind: `closure-${p.kind}`, detail: p.detail, file: p.file });
1141
1169
  if (!adv.data) gaps.push({ kind: 'closure-advisory-feed-unavailable', detail: `${adv.reason}. The closure's components were NOT checked against any advisory: the absence of findings is not a clean result.` });
1142
1170
  else if (adv.data.stale) gaps.push({ kind: 'closure-advisory-feed-stale', detail: `the Nix advisory snapshot is stale or undated (${adv.data.ageDays == null ? 'age unknown' : `${Math.floor(adv.data.ageDays)} day(s) old`})` });
@@ -174,8 +174,10 @@ function judgeFixWitness(before, after) {
174
174
  function haskellRunner() {
175
175
  const p = which('runghc') || which('runhaskell');
176
176
  if (!p) return { available: false, reason: 'no Haskell toolchain (runghc) on PATH; witnesses for Haskell findings are unavailable here' };
177
- const v = (0,node_child_process__WEBPACK_IMPORTED_MODULE_3__.spawnSync)(p, ['--version'], { encoding: 'utf8', timeout: 10_000 });
178
- return v.status === 0 ? { available: true, path: p, version: String(v.stdout || v.stderr).trim() } : { available: false, reason: 'the Haskell runner did not start' };
177
+ // GHC's first start on a cold, loaded machine (a CI runner) has taken more than 10 s; a probe that gives up there reports a toolchain
178
+ // that is installed as absent, and the answer then differs between two calls in one run.
179
+ const v = (0,node_child_process__WEBPACK_IMPORTED_MODULE_3__.spawnSync)(p, ['--version'], { encoding: 'utf8', timeout: 60_000 });
180
+ return v.status === 0 ? { available: true, path: p, version: String(v.stdout || v.stderr).trim() } : { available: false, reason: v.error && v.error.code === 'ETIMEDOUT' ? 'the Haskell runner did not start within 60 s' : 'the Haskell runner did not start' };
179
181
  }
180
182
 
181
183
  /**