@clear-capabilities/agentic-security-scanner 0.195.8 → 0.195.9
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 +12 -0
- package/dist/{06198b6f3fac9b1cb25f.json → 851efefbfcfa2a9aa691.json} +3 -3
- package/dist/agentic-security.mjs +2 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/language/CLAUDE.md +6 -2
- 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 +130 -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
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
// is lowered to `info`, never dropped
|
|
11
11
|
// api-contract the value is a parameter of an exported function that nothing in the analysed code calls, and the function
|
|
12
12
|
// hands it WHOLE (or as the root it appends fixed text to) to a path or command operation: a thin wrapper.
|
|
13
|
-
// The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info
|
|
13
|
+
// The caller chose what to read or run, and the wrapper has no fixed part to escape from. Lowered to `info`.
|
|
14
|
+
// Path operations joined this way since 0.195.9: `renderFile path = responseFile .. path` and
|
|
15
|
+
// `store path = readFile (cs path)` were 2 of the 29 flagged path sites on the real-code sets and no true
|
|
16
|
+
// positive had that shape; a parameter spliced UNDER a fixed directory (`"/srv/" ++ name`) is still kept,
|
|
17
|
+
// because the function chose the directory and lets the caller leave it
|
|
14
18
|
// entry-parameter a parameter of such a function used inside a larger value the function builds: its own construction, kept
|
|
15
19
|
// data-input standard input or a file's content in a service or a library: data the program processes, kept
|
|
16
20
|
// unattributed the engine recorded no origin: kept
|
|
@@ -30,10 +34,13 @@ const EXTERNAL = new Set(['http-body', 'url-param', 'path-param', 'header', 'coo
|
|
|
30
34
|
const DATA = new Set(['stdin', 'file-read']);
|
|
31
35
|
const WEAK_PARAM_LABEL = /\(caller-controlled\)$/;
|
|
32
36
|
// CWE-918 is deliberately NOT a wrapper family: `fetch url = parseRequest url >>= httpNoBody` is pinned as a finding in
|
|
33
|
-
// test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets)
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
+
// test/haskell/haskell-generalization.test.js (a URL the caller chooses is the SSRF shape of the development sets). unseen-v4 labels
|
|
38
|
+
// that shape safe when the URL is configuration; that disagreement is recorded, not decided here. CWE-22 joined the set in 0.195.9
|
|
39
|
+
// (see the header): the synthetic development sets label `load name = readFile name` vulnerable, the real-code sets label every such
|
|
40
|
+
// site a false positive, and the real code won; a parameter under a fixed directory is still `entry-parameter`.
|
|
41
|
+
const WRAPPER_FAMILIES = new Set(['CWE-78', 'CWE-88', 'CWE-22']);
|
|
42
|
+
// `cs`/`convertString` (string-conversions) and `toS` (protolude) change the type of a text value and nothing else
|
|
43
|
+
const CONVERSIONS = /(?:^|\.)(?:pack|unpack|fromString|toString|show|encodeUtf8|decodeUtf8|toStrict|fromStrict|toText|unpackFS|fromText|cs|convertString|toS|toSL|toST)$/;
|
|
37
44
|
const PATH_JOIN = /(?:^|\.)(?:<\/>|combine|joinPath|<\.>)$/;
|
|
38
45
|
// list plumbing that only lays the parameters out as text (`unwords args`, `concatMap (' ':) args`): no fixed words are added
|
|
39
46
|
const LIST_LAYOUT = /(?:^|\.)(?:unwords|unlines|intercalate|concatMap|concat|map|fmap)$/;
|
|
@@ -261,9 +268,14 @@ export function applyTrustBoundary(findings, perFile) {
|
|
|
261
268
|
else if (roots.length && [...classes].every((c) => c === 'operator')) {
|
|
262
269
|
verdict = 'operator-controlled';
|
|
263
270
|
reason = 'only command-line arguments or environment variables supply the value; whoever starts the program chooses them, so no trust boundary is crossed';
|
|
264
|
-
} else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance))) {
|
|
271
|
+
} else if (roots.length && role === 'cli' && roots.every((r) => r.provenance === 'file-read' || OPERATOR.has(r.provenance) || (f.cwe === 'CWE-22' && r.provenance === 'stdin'))) {
|
|
272
|
+
// Standard input counts here for a PATH operation only: a command-line program reading a file name from its own standard input
|
|
273
|
+
// is the person at the keyboard naming a file (14 of the 29 flagged path sites on the real-code sets, every one a false positive).
|
|
274
|
+
// A command built from standard input stays reported (`getLine >>= callCommand`, pinned in test/haskell/haskell-trust-boundary.test.js).
|
|
265
275
|
verdict = 'operator-controlled';
|
|
266
|
-
reason =
|
|
276
|
+
reason = f.cwe === 'CWE-22' && roots.some((r) => r.provenance === 'stdin')
|
|
277
|
+
? 'the function belongs to a command-line program (reached from a main that imports no server framework) and the file it works on is named by the person running it: on its standard input, in files it was pointed at, or in its arguments and environment'
|
|
278
|
+
: 'the function belongs to a command-line program (reached from a main that imports no server framework) and the value comes from files the person running it pointed the program at, or from its arguments and environment; standard input is covered by this rule only for a path operation';
|
|
267
279
|
} else if (classes.has('data')) { verdict = 'data-input'; reason = 'standard input or file content supplies the value'; }
|
|
268
280
|
else if (classes.has('weak-param')) {
|
|
269
281
|
verdict = 'entry-parameter';
|
|
@@ -38,8 +38,12 @@ const SCOTTY_METHODS = { get: 'GET', post: 'POST', put: 'PUT', delete: 'DELETE',
|
|
|
38
38
|
const SENSITIVE_PREFIX = ['Database.', 'System.Process.', 'System.Directory.', 'Prelude.readFile', 'Prelude.writeFile', 'Prelude.appendFile', 'System.IO.', 'Network.HTTP.Simple.', 'Network.HTTP.Client.'];
|
|
39
39
|
const PERSIST_FNS = new Set(['get', 'getBy', 'selectFirst', 'selectList', 'insert', 'insert_', 'update', 'updateWhere', 'delete', 'deleteWhere', 'replace', 'runDB', 'getJust', 'upsert']);
|
|
40
40
|
const GUARD_PRIMITIVES = new Set(['Yesod.Core.requireAuthId', 'Yesod.Core.requireAuth', 'Yesod.Auth.requireAuthId', 'Yesod.Auth.requireAuth', 'Yesod.requireAuthId', 'Yesod.requireAuth', 'Network.Wai.Middleware.HttpAuth.basicAuth']);
|
|
41
|
-
|
|
41
|
+
// a header that carries the caller's credential: the standard two, and the custom names projects give a token or session header
|
|
42
|
+
const CREDENTIAL_HEADER = /^(?:authorization|cookie|x-api-key|x-api-token|x-auth-token|x-auth|x-access-token|x-session(?:-(?:id|token|key))?|x-token|x-user-token|x-csrf-token|x-xsrf-token)$/i;
|
|
42
43
|
const ADMIN_PATH = /(?:^|\/)(?:admin|internal|manage|superuser)(?:\/|$|:)/i;
|
|
44
|
+
// an authentication endpoint (sign-in, registration, password reset, logout, an OAuth callback) is public by construction
|
|
45
|
+
const AUTH_ENDPOINT_PATH = /(?:^|\/)(?:login|log-in|sign-?in|sign-?up|signup|register|registration|logout|log-out|sign-?out|password\/(?:reset|forgot)|reset-password|forgot-password|password-reset|oauth|callback|token|auth|authenticate|session)(?:\/|$|:|\.)/i;
|
|
46
|
+
const AUTH_ENDPOINT_NAME = /(?:login|signin|signup|register|logout|signout|resetpassword|passwordreset|forgotpassword|authenticate)/i;
|
|
43
47
|
const ROLE_CALL = /(?:^|\.)(?:isAdmin|hasRole|requireAdmin|checkRole|hasPermission|requireRole|requirePermission|isSuperuser)$/i;
|
|
44
48
|
const OWNER_CALL = /(?:^|\.)(?:checkOwner|ownedBy|belongsTo|isOwner|requireOwner|authorizeOwner)$/i;
|
|
45
49
|
const CSRF_CALL = /csrf|xsrf/i;
|
|
@@ -1160,8 +1164,11 @@ function emitFindings(ctx, result, yesodAuthz) {
|
|
|
1160
1164
|
if (!R.handler || R.handler.kind === 'unknown') continue;
|
|
1161
1165
|
const writes = WRITE_METHODS.has(R.method) || R.method === 'ANY';
|
|
1162
1166
|
const privileged = ADMIN_PATH.test(R.path) || /admin/i.test(R.handler.name || '');
|
|
1167
|
+
// An authentication endpoint is public by construction: the request that signs in, registers or starts a password reset carries no
|
|
1168
|
+
// session yet. Its own flaws (a weak hash, a predictable code) are other families' findings; a missing-auth finding on it is noise.
|
|
1169
|
+
const authEndpoint = AUTH_ENDPOINT_PATH.test(R.path || '') || AUTH_ENDPOINT_NAME.test(R.handler.name || '');
|
|
1163
1170
|
if (R.auth.status === 'late') emit('hs-route-late-auth', R, { detail: R.auth.evidence[0].order === 'unknown' ? `the rejection ${R.auth.evidence[0].callee} cannot be shown to run before the first sensitive operation (${R.auth.evidence[0].reason})` : `guard ${R.auth.evidence[0].callee} runs after the first sensitive operation`, evidence: R.auth.evidence });
|
|
1164
|
-
else if (R.auth.status === 'none' && (writes || privileged) && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
|
|
1171
|
+
else if (R.auth.status === 'none' && (writes || privileged) && !authEndpoint && !(R.framework === 'yesod' && R.yesodExplicitPublic && !writes)) {
|
|
1165
1172
|
const how = R.method === 'ANY' ? 'any method' : writes ? 'state-changing method' : 'privileged path';
|
|
1166
1173
|
if (R.sensitive !== false) emit('hs-route-missing-auth', R, { severity: writes ? 'high' : 'medium', detail: how });
|
|
1167
1174
|
// No database, file or process call is visible, but the handler calls something the engine cannot see into (a project function, a
|
|
@@ -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')) {
|