@clear-capabilities/agentic-security-scanner 0.195.8 → 0.195.10
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 +22 -0
- package/dist/{06198b6f3fac9b1cb25f.json → 312ef9976a988b322ade.json} +3 -3
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/language/CLAUDE.md +14 -3
- package/src/language/haskell-fix.js +251 -5
- package/src/language/haskell-guards.js +131 -5
- package/src/language/haskell-ir.js +9 -3
- package/src/language/haskell-models.js +5 -0
- package/src/language/haskell-security-rules.js +173 -13
- package/src/language/haskell-trust.js +19 -7
- package/src/language/haskell-web.js +9 -2
- package/src/language/nix-script-taint.js +13 -0
- package/src/language/nix-secrets.js +14 -0
- package/src/language/nixos-hardening.js +20 -3
- package/src/language/support-registry.js +2 -0
|
@@ -818,8 +818,20 @@ export function analyzeNixScripts(opts = {}) {
|
|
|
818
818
|
}
|
|
819
819
|
const envMap = collectEnvironment(site, ev);
|
|
820
820
|
|
|
821
|
+
const emitted = new Map();
|
|
821
822
|
const emit = (rule, o) => {
|
|
822
823
|
const R = RULES[rule];
|
|
824
|
+
// One finding per rule, script line and origin class: `rsync ${cfg.source} ${cfg.destination}` is one unescaped command line
|
|
825
|
+
// with two configuration interpolations, not two findings. Origins of different classes on one line (an attacker value next
|
|
826
|
+
// to a configuration value) stay separate findings, as NIX-003.AC04 requires; the later same-class interpolations join the first.
|
|
827
|
+
const kinds = [...new Set((o.origins || []).map((x) => x.kind).filter((k) => k !== 'constant'))].sort().join('+');
|
|
828
|
+
const dupKey = `${rule}|${siteFile}|${o.line}|${kinds}`;
|
|
829
|
+
const prior = emitted.get(dupKey);
|
|
830
|
+
if (prior) {
|
|
831
|
+
for (const x of o.origins || []) if (!prior.origins.some((y) => y.kind === x.kind && y.detail === x.detail)) prior.origins.push({ kind: x.kind, detail: x.detail });
|
|
832
|
+
prior.alsoAt = [...(prior.alsoAt || []), { column: o.column }];
|
|
833
|
+
return prior;
|
|
834
|
+
}
|
|
823
835
|
const f = {
|
|
824
836
|
id: `NIX-SCRIPT-${rule}-${siteFile}:${o.line}:${o.column}`,
|
|
825
837
|
severity: o.severity, file: siteFile, line: o.line, column: o.column, vuln: o.vuln || R.vuln, cwe: R.cwe,
|
|
@@ -832,6 +844,7 @@ export function analyzeNixScripts(opts = {}) {
|
|
|
832
844
|
...(o.uncertainty ? { uncertainty: o.uncertainty } : {}), ...(o.extra || {}),
|
|
833
845
|
};
|
|
834
846
|
findings.push(f);
|
|
847
|
+
emitted.set(dupKey, f);
|
|
835
848
|
return f;
|
|
836
849
|
};
|
|
837
850
|
|
|
@@ -275,6 +275,20 @@ class Walker {
|
|
|
275
275
|
}
|
|
276
276
|
return;
|
|
277
277
|
}
|
|
278
|
+
// `passwordFile = pkgs.writeText "x" "literal"`: an option that expects a RUNTIME path to a secret is handed a file the evaluation
|
|
279
|
+
// writes into the store, with the secret's text written here. The option name says what the file holds; the writer says where it lands.
|
|
280
|
+
if (v && v.type === 'app' && PATHISH_TAIL.test(last) && SECRET_NAME.test(last.replace(PATHISH_TAIL, ''))) {
|
|
281
|
+
const { fn, args } = this.ev.flatten(v);
|
|
282
|
+
const c = this.ev.calleeOf(fn);
|
|
283
|
+
const idx = c.name ? WRITERS.get(c.name) : undefined;
|
|
284
|
+
const content = Number.isInteger(idx) && args[idx] ? unparen(args[idx]) : null;
|
|
285
|
+
if (content && content.type === 'string' && !content.interpolated && plausibleSecret(content.literal)) {
|
|
286
|
+
this.scriptKeys.add(`${file}:${content.start}`);
|
|
287
|
+
const option = full.filter(Boolean).join('.');
|
|
288
|
+
this.emit('nix-secret-plaintext', { ...this.at(file, bspan), file, severity: 'high', exposure: 'store', description: `${option} expects a runtime path to a secret but is given a file that ${c.name} writes into the world-readable Nix store, with the value written here as a literal (value redacted).`, source: { kind: 'literal', file, line: content.span ? content.span.startLine : (bspan ? bspan.startLine : 1) }, destination: { kind: 'store', label: `${c.name} file`, exposure: 'store' }, evidence: { ...redact(content.literal), option, writer: c.name }, originalLocation: bspan, chain: [hop(file, content.span, 'literal'), hop(file, bspan, `${c.name} into ${last}`)].filter(Boolean), origins: [mkOrigin('secret', `literal handed to ${c.name}`)] });
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
278
292
|
// evaluate the value for secret origins
|
|
279
293
|
if (!v || v.type === 'attrset' || v.type === 'lambda' || v.type === 'list') return;
|
|
280
294
|
if (this.isRuntimeRef(v, env)) { this.safe.push({ file, line: bspan && bspan.startLine, attr: full.filter(Boolean).join('.'), reason: 'runtime secret reference' }); return; }
|
|
@@ -339,8 +339,11 @@ export function analyzeNixosHardening(opts, extra = {}) {
|
|
|
339
339
|
const sv = view(sudoCfg);
|
|
340
340
|
if (sv.state === 'known' && typeof sv.value === 'string' && sudoState !== 'inactive' && sudoState !== 'unknown') {
|
|
341
341
|
const text = stripNixHash(sv.value);
|
|
342
|
-
|
|
343
|
-
|
|
342
|
+
// `Defaults !authenticate` (optionally scoped, `Defaults:%wheel !authenticate`) turns the password off for every rule the
|
|
343
|
+
// scope matches, which is the same grant as NOPASSWD: ALL written as a default
|
|
344
|
+
const noAuth = /^\s*Defaults(?:[:>@!]\S+)?\s+(?:[\w,]+\s*,\s*)?!authenticate\b/m.test(text);
|
|
345
|
+
if (/NOPASSWD\s*:\s*ALL\b/.test(text) || noAuth) {
|
|
346
|
+
addFinding('sudo-nopasswd-all', { anchor: anchorOf(sudoCfg), scope: 'security.sudo.extraConfig', state: sudoState === 'conditional' ? 'conditional' : 'active', uncertain: !sv.definite, evidence: [evidenceOf(sudoCfg)], ...(noAuth ? { extra: { directive: 'Defaults !authenticate' } } : {}) });
|
|
344
347
|
entryPoints.push({ id: 'sudo-nopasswd-all', kind: 'sudo-nopasswd', state: sudoState === 'conditional' ? 'conditional' : 'active', privilege: 'root', evidence: [evidenceOf(sudoCfg)] });
|
|
345
348
|
}
|
|
346
349
|
}
|
|
@@ -525,6 +528,18 @@ export function analyzeNixosHardening(opts, extra = {}) {
|
|
|
525
528
|
}
|
|
526
529
|
}
|
|
527
530
|
|
|
531
|
+
// ── TLS private keys of other services (postfix `sslKey`, dovecot `sslServerKey`, httpd `sslServerKey`, a `keyFile`/`tlsKey`/`privateKeyFile`)
|
|
532
|
+
// The same rule as nginx above: a path literal or a store-writer call puts the key in the store; a string is a runtime path.
|
|
533
|
+
for (const o of cfg.options.filter((x) => x.namespace === 'nixos' && /^services\.(?!nginx\.)[^.]+\.(?:.*\.)?(?:sslKey|sslServerKey|sslCertificateKey|tlsKey|keyFile|privateKeyFile|tlsKeyFile)$/.test(x.path))) {
|
|
534
|
+
const svc = o.path.split('.')[1];
|
|
535
|
+
const svcState = stateOf(nixos(`services.${svc}.enable`));
|
|
536
|
+
if (svcState !== 'active' && svcState !== 'conditional') continue;
|
|
537
|
+
const st = svcState === 'conditional' ? 'conditional' : 'active';
|
|
538
|
+
const node = rhsNodeOf(o, opts.files);
|
|
539
|
+
if (node && (hasPathNode(node) || isStoreCopy(node))) addFinding('tls-key-in-store', { anchor: anchorOf(o), scope: o.path, state: st, uncertain: false, evidence: [evidenceOf(o)] });
|
|
540
|
+
else if (view(o).state === 'known' && typeof view(o).value === 'string') controls.push({ control: 'tls-key-runtime-path', option: o.path, applies: true });
|
|
541
|
+
}
|
|
542
|
+
|
|
528
543
|
// ── firewall ──
|
|
529
544
|
const listeningNow = inventory.filter((i) => i.state === 'active' && i.listening !== 'no' && i.listening !== 'localhost-only');
|
|
530
545
|
if (fwOff === 'yes' || fwOff === 'conditional') {
|
|
@@ -635,7 +650,9 @@ export function analyzeNixosHardening(opts, extra = {}) {
|
|
|
635
650
|
const r = sc(key);
|
|
636
651
|
if (!r) continue;
|
|
637
652
|
const v = view(r);
|
|
638
|
-
|
|
653
|
+
// systemd accepts the capability set as a list or as one space-separated string (`AmbientCapabilities = "CAP_SYS_ADMIN"`)
|
|
654
|
+
const raw = v.state === 'known' ? v.value : v.state === 'conditional' ? v.definiteItems : [];
|
|
655
|
+
const items = typeof raw === 'string' ? raw.split(/\s+/).filter(Boolean) : asList(raw);
|
|
639
656
|
const bad = items.filter((c) => typeof c === 'string' && !c.startsWith('~') && DANGEROUS_CAPS.has(c));
|
|
640
657
|
// a bounding set only limits; an ambient set grants, so only Ambient is a hard finding for non-root-looking services
|
|
641
658
|
if (bad.length && (key === 'AmbientCapabilities' || identity.state !== 'root-default')) {
|
|
@@ -243,6 +243,8 @@ export function evaluateSupport(language, input) {
|
|
|
243
243
|
for (const [cap, req] of Object.entries(REQUIRES_TOOL[language] || {})) {
|
|
244
244
|
const r = out.rows[cap];
|
|
245
245
|
if (r && !tools[req.tool]) out.rows[cap] = { ...r, status: 'blocked', reasons: [`${req.tool} is not available on the measuring host: ${req.why}`, ...r.reasons] };
|
|
246
|
+
// a GHC other than the recorded toolchain compiles the fixtures against an empty package set: not a verdict on the engine
|
|
247
|
+
else if (r && req.tool === 'ghc' && tools.ghcMatchesRecorded === false && r.status !== 'supported') out.rows[cap] = { ...r, status: 'blocked', reasons: [`ghc ${tools.ghcVersion} on the measuring host is not the recorded toolchain (${tools.ghcRecorded}); the criterion that needs it could not be judged`, ...r.reasons] };
|
|
246
248
|
}
|
|
247
249
|
const rows = Object.values(out.rows);
|
|
248
250
|
out.summary = { required: rows.length, supported: rows.filter((r) => r.status === 'supported').length, notSupported: rows.filter((r) => r.status !== 'supported').map((r) => `${r.capability}:${r.status}`), dataOk: dataOk.ok };
|