@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.
- package/CHANGELOG.md +223 -0
- package/bin/agentic-security.js +52 -2
- package/dist/11.index.js +2 -2
- package/dist/113.index.js +498 -7
- package/dist/178.index.js +1 -1
- package/dist/207.index.js +220 -0
- package/dist/238.index.js +218 -0
- package/dist/259.index.js +975 -0
- package/dist/384.index.js +1 -1
- package/dist/415.index.js +1 -1
- package/dist/435.index.js +4 -4
- package/dist/526.index.js +844 -0
- package/dist/637.index.js +1 -1
- package/dist/830.index.js +1 -1
- package/dist/agentic-security.mjs +106 -194
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +33 -17
- package/src/dataflow/CLAUDE.md +4 -1
- package/src/dataflow/async-sequencing.js +8 -3
- package/src/dataflow/catalog.js +278 -11
- package/src/dataflow/cross-repo.js +1 -1
- package/src/dataflow/cross-service-taint.js +1 -1
- package/src/dataflow/engine.js +182 -61
- package/src/dataflow/ifds.js +10 -5
- package/src/dataflow/index.js +15 -3
- package/src/dataflow/points-to.js +8 -2
- package/src/dataflow/proof-gate.js +7 -0
- package/src/dataflow/sanitizer-gate.js +89 -0
- package/src/dataflow/tabulation.js +14 -3
- package/src/engine.js +170 -7
- package/src/integrations/index.js +1 -1
- package/src/ir/CLAUDE.md +49 -4
- package/src/ir/call-sites.js +66 -0
- package/src/ir/callgraph.js +174 -7
- package/src/ir/class-hierarchy.js +22 -2
- package/src/ir/index.js +138 -51
- package/src/ir/ir-stats.js +126 -0
- package/src/ir/parser-cpp.js +829 -0
- package/src/ir/parser-cs.js +4 -1
- package/src/ir/parser-go.js +4 -1
- package/src/ir/parser-js.js +13 -1
- package/src/ir/parser-kt.js +4 -1
- package/src/ir/parser-php.js +10 -3
- package/src/ir/parser-py-cst.js +62 -10
- package/src/ir/tree-sitter-loader.js +13 -1
- package/src/llm-validator/index.js +9 -2
- package/src/llm-validator/redact.js +157 -0
- package/src/mcp/tools.js +2 -2
- package/src/posture/CLAUDE.md +193 -1
- package/src/posture/accuracy-scorecard.js +317 -0
- package/src/posture/api-contract.js +1 -1
- package/src/posture/attestation.js +202 -0
- package/src/posture/auditor-walkthrough.js +12 -3
- package/src/posture/compliance-policy.js +1 -1
- package/src/posture/corpus-enroll.js +303 -0
- package/src/posture/corpus-match.js +52 -0
- package/src/posture/cross-lang-openapi.js +1 -1
- package/src/posture/custom-rules.js +3 -3
- package/src/posture/execution-proof.js +92 -0
- package/src/posture/exploitability-probability.js +1 -1
- package/src/posture/falsification.js +45 -1
- package/src/posture/fix-metrics.js +197 -0
- package/src/posture/fix-verify.js +129 -2
- package/src/posture/license-policy.js +1 -1
- package/src/posture/profile.js +1 -1
- package/src/posture/proof-tier.js +33 -0
- package/src/posture/relevance.js +379 -0
- package/src/posture/root-cause-sweep.js +0 -0
- package/src/posture/rule-overrides.js +1 -1
- package/src/posture/sca-policy.js +1 -1
- package/src/posture/scan-checkpoint.js +277 -0
- package/src/posture/suppressions.js +1 -1
- package/src/posture/test-runner.js +147 -0
- package/src/posture/verification-separation.js +131 -0
- package/src/report/index.js +11 -0
- package/src/runScan.js +5 -7
- package/src/sandbox/CLAUDE.md +340 -0
- package/src/sandbox/backend-disabled.js +14 -0
- package/src/sandbox/backend-namespace.js +335 -0
- package/src/sandbox/backend-userspace.js +83 -0
- package/src/sandbox/capabilities.js +181 -0
- package/src/sandbox/index.js +30 -0
- package/src/sandbox/limits.js +63 -0
- package/src/sandbox/result.js +104 -0
- package/src/sca/dep-confusion.js +1 -1
- package/src/util/glob.js +173 -0
- package/src/util/yaml.js +24 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Resource caps applied as a shell prelude, shared by every real backend.
|
|
2
|
+
//
|
|
3
|
+
// Address-space capping (`ulimit -v`) is NOT enforced on the macOS family —
|
|
4
|
+
// verified by execution. We therefore DECLARE it unsupported rather than
|
|
5
|
+
// emitting a limit that silently does nothing, which would be a false
|
|
6
|
+
// assurance of containment.
|
|
7
|
+
import { spawnSync } from 'node:child_process';
|
|
8
|
+
|
|
9
|
+
// `ulimit -u` (RLIMIT_NPROC) is charged per *uid*, system-wide, on both
|
|
10
|
+
// platforms this module supports — it is not a per-process-tree cap. A fixed
|
|
11
|
+
// default like the 64 below therefore breaks ordinary, non-adversarial runs on
|
|
12
|
+
// any host whose user already owns more than ~64 processes, which is most of
|
|
13
|
+
// them: the confined shell cannot even fork the helpers it needs to set itself
|
|
14
|
+
// up, and the failure looks like a broken sandbox rather than a cap doing its
|
|
15
|
+
// job. So unless a caller passes an explicit `maxProcs`, both real backends
|
|
16
|
+
// derive one from the ambient count for this uid. That keeps default behaviour
|
|
17
|
+
// usable without pretending a low fixed cap is real containment — see the
|
|
18
|
+
// "fork-storm containment is weak" note in the module guide.
|
|
19
|
+
export function ambientRelativeMaxProcs(headroom = 64) {
|
|
20
|
+
let ambient = 200;
|
|
21
|
+
try {
|
|
22
|
+
const out = spawnSync('/bin/sh', ['-c', 'ps -U "$(id -un)" -o pid= | wc -l'], { encoding: 'utf8' });
|
|
23
|
+
ambient = Number(String(out.stdout || '').trim()) || 200;
|
|
24
|
+
} catch { /* fall through to the conservative default */ }
|
|
25
|
+
return ambient + headroom;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function buildLimitPrelude({
|
|
29
|
+
maxProcs = 64,
|
|
30
|
+
maxFileSizeKb = 65536,
|
|
31
|
+
maxAddressSpaceKb = null,
|
|
32
|
+
} = {}) {
|
|
33
|
+
const parts = [];
|
|
34
|
+
const unsupported = [];
|
|
35
|
+
|
|
36
|
+
if (maxProcs != null) parts.push(`ulimit -u ${_num('maxProcs', maxProcs)}`);
|
|
37
|
+
if (maxFileSizeKb != null) parts.push(`ulimit -f ${_num('maxFileSizeKb', maxFileSizeKb)}`);
|
|
38
|
+
|
|
39
|
+
if (maxAddressSpaceKb != null) {
|
|
40
|
+
if (process.platform === 'linux') parts.push(`ulimit -v ${_num('maxAddressSpaceKb', maxAddressSpaceKb)}`);
|
|
41
|
+
else unsupported.push('maxAddressSpaceKb');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const prelude = parts.length ? parts.join('; ') + '; ' : '';
|
|
45
|
+
return { prelude, unsupported };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Limit values are interpolated into a shell fragment, so a non-numeric value
|
|
50
|
+
* is shell text. Verified by execution: `maxProcs: '999; echo INJECTED'`
|
|
51
|
+
* emitted `ulimit -u 999; echo INJECTED` and the payload ran. That is not a
|
|
52
|
+
* sandbox escape (the prelude runs INSIDE the confinement) but it lets a
|
|
53
|
+
* config-derived value silently DISABLE the very limits it was meant to set —
|
|
54
|
+
* e.g. `'0 2>/dev/null; true'` swallows the failure. Coerce and reject
|
|
55
|
+
* anything that is not a finite, non-negative number.
|
|
56
|
+
*/
|
|
57
|
+
function _num(name, v) {
|
|
58
|
+
const n = Number(v);
|
|
59
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
60
|
+
throw new RangeError(`${name} must be a finite, non-negative number (got ${JSON.stringify(v)})`);
|
|
61
|
+
}
|
|
62
|
+
return Math.floor(n);
|
|
63
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Shared result construction for every real backend.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS (the misread it prevents): status used to be derived purely
|
|
4
|
+
// from the exit code — `exitCode !== 0` was reported as `'blocked'`. That
|
|
5
|
+
// conflates two entirely different outcomes:
|
|
6
|
+
//
|
|
7
|
+
// 1. A program that ran fine and chose to exit non-zero (a failing test, a
|
|
8
|
+
// grep with no match) was labelled 'blocked' — a false confinement claim.
|
|
9
|
+
// 2. A program whose out-of-root write was DENIED but which still exited 0
|
|
10
|
+
// was labelled 'ok' — the caller saw a clean run and could not tell that
|
|
11
|
+
// the sandbox had refused something. Verified by execution: the denied
|
|
12
|
+
// write returns exit 0 when the command swallows the failure.
|
|
13
|
+
//
|
|
14
|
+
// So the two signals are now separated:
|
|
15
|
+
//
|
|
16
|
+
// - `denied` — a confinement violation was OBSERVED in the child's error
|
|
17
|
+
// output. Best effort, see the honesty note below.
|
|
18
|
+
// - `status` — 'blocked' when a denial was observed, 'nonzero' when the
|
|
19
|
+
// command merely exited non-zero with no denial signal, 'ok'
|
|
20
|
+
// only when it exited 0 with no denial signal.
|
|
21
|
+
//
|
|
22
|
+
// HONESTY NOTE — what `denied:false` does and does not mean. The denial signal
|
|
23
|
+
// is read from the confined process's own stderr (the OS primitives here do
|
|
24
|
+
// not hand the parent a structured violation channel). A program that writes
|
|
25
|
+
// out of root and swallows its own error message produces NO signal, so
|
|
26
|
+
// `denied:false` means "no denial was observed", NOT "no denial occurred".
|
|
27
|
+
// Never treat `status:'ok'` as proof that nothing was refused. It is proof
|
|
28
|
+
// only that the command exited 0 and said nothing about a refusal.
|
|
29
|
+
|
|
30
|
+
const DENIAL_PATTERNS = [
|
|
31
|
+
/operation not permitted/i,
|
|
32
|
+
/permission denied/i,
|
|
33
|
+
/read-only file system/i,
|
|
34
|
+
/deny file-write/i,
|
|
35
|
+
/deny network/i,
|
|
36
|
+
/network is unreachable/i,
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** True iff the confined process's error output shows an observed denial. */
|
|
40
|
+
export function detectDenial(stderr) {
|
|
41
|
+
const s = String(stderr || '');
|
|
42
|
+
return DENIAL_PATTERNS.some((re) => re.test(s));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Build the single result shape every backend returns. `status` is one of
|
|
47
|
+
* 'ok' | 'blocked' | 'nonzero' | 'timeout' | 'disabled' | 'error'.
|
|
48
|
+
*/
|
|
49
|
+
export function buildResult({ backend, spawnResult, unsupported = [] }) {
|
|
50
|
+
const r = spawnResult;
|
|
51
|
+
const rawStderr = r.stderr ?? '';
|
|
52
|
+
const timedOut = r.error?.code === 'ETIMEDOUT';
|
|
53
|
+
const denied = detectDenial(rawStderr);
|
|
54
|
+
|
|
55
|
+
let status;
|
|
56
|
+
if (timedOut) status = 'timeout';
|
|
57
|
+
else if (r.error) status = 'error';
|
|
58
|
+
else if (denied) status = 'blocked';
|
|
59
|
+
else if (r.status !== 0) status = 'nonzero';
|
|
60
|
+
else status = 'ok';
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
status,
|
|
64
|
+
denied,
|
|
65
|
+
stdout: r.stdout ?? '',
|
|
66
|
+
stderr: rawStderr + (unsupported.length ? `\n[sandbox] not enforceable here: ${unsupported.join(', ')}` : ''),
|
|
67
|
+
exitCode: r.status ?? null,
|
|
68
|
+
timedOut,
|
|
69
|
+
backend,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Documented-shape error result: runConfined never throws at its callers. */
|
|
74
|
+
export function errorResult(backend, message) {
|
|
75
|
+
return {
|
|
76
|
+
status: 'error',
|
|
77
|
+
denied: false,
|
|
78
|
+
stdout: '',
|
|
79
|
+
stderr: `agentic-security: ${message}`,
|
|
80
|
+
exitCode: null,
|
|
81
|
+
timedOut: false,
|
|
82
|
+
backend,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Minimal environment handed to untrusted code. The parent environment is NOT
|
|
88
|
+
* forwarded: it routinely carries credentials (tokens, cloud keys, registry
|
|
89
|
+
* auth) and the confined program can read and exfiltrate them. This is a
|
|
90
|
+
* distinct exposure from the accepted "reads are not confined" scope cut —
|
|
91
|
+
* that one is about files on disk, this one is about secrets the parent hands
|
|
92
|
+
* over for free. Callers that need extra variables pass them explicitly via
|
|
93
|
+
* `opts.env`, which is merged on top of this base.
|
|
94
|
+
*/
|
|
95
|
+
export function buildConfinedEnv({ root, env = {} } = {}) {
|
|
96
|
+
return {
|
|
97
|
+
PATH: '/usr/bin:/bin:/usr/sbin:/sbin',
|
|
98
|
+
ROOT: root,
|
|
99
|
+
HOME: root,
|
|
100
|
+
TMPDIR: root,
|
|
101
|
+
LANG: 'C',
|
|
102
|
+
...env,
|
|
103
|
+
};
|
|
104
|
+
}
|
package/src/sca/dep-confusion.js
CHANGED
package/src/util/glob.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// File discovery on top of the Node standard library only.
|
|
2
|
+
//
|
|
3
|
+
// Two entry points, both replacing a third-party glob package that used to be a
|
|
4
|
+
// production dependency:
|
|
5
|
+
//
|
|
6
|
+
// listFiles(root, {ignore}) — the scan-tree walk behind readScan/readTree.
|
|
7
|
+
// globFiles(pattern, {cwd}) — resolve a user-supplied glob to regular files.
|
|
8
|
+
//
|
|
9
|
+
// `globFiles` is a thin wrapper: `fs.promises.glob` matches the old behaviour
|
|
10
|
+
// for the way those call sites used it (dot:false, onlyFiles:true, symlinks
|
|
11
|
+
// followed), so all it adds is the files-only filter.
|
|
12
|
+
//
|
|
13
|
+
// `listFiles` cannot be a thin wrapper, and the reasons are worth stating
|
|
14
|
+
// precisely, because each one is a way file discovery could silently change:
|
|
15
|
+
//
|
|
16
|
+
// * dot — the built-in glob (like every minimatch-derived matcher) will not
|
|
17
|
+
// let `*` or `**` match a path segment starting with `.`, and exposes no
|
|
18
|
+
// option to change that. The scan tree must include hidden files and must
|
|
19
|
+
// descend into hidden directories, so the walk is done with fs.readdir
|
|
20
|
+
// instead, where visibility is simply not a concept.
|
|
21
|
+
// * onlyFiles — a glob walk yields directories as well; only regular files
|
|
22
|
+
// are wanted. readdir's Dirent gives that directly, with no extra stat.
|
|
23
|
+
// * followSymbolicLinks:false — the built-in glob follows symlinked
|
|
24
|
+
// directories with no option to stop it, which would let a link inside the
|
|
25
|
+
// tree pull unrelated content (or content outside the scan root) into the
|
|
26
|
+
// scan. readdir's Dirent is lstat-derived: a symlink reports isSymbolicLink
|
|
27
|
+
// and neither isDirectory nor isFile, so links are skipped by construction
|
|
28
|
+
// and their targets are never reached.
|
|
29
|
+
// * suppressErrors — an unreadable directory must not abort the walk. Each
|
|
30
|
+
// readdir is individually guarded.
|
|
31
|
+
// * ignore — patterns are still matched with the built-in path.matchesGlob;
|
|
32
|
+
// only the dot-blindness above is compensated for (see unhide()).
|
|
33
|
+
//
|
|
34
|
+
// Ordering matches the previous implementation's breadth-first shape: every
|
|
35
|
+
// entry of one depth is emitted before any entry of the next.
|
|
36
|
+
import * as fs from 'node:fs/promises';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
|
|
39
|
+
// Two properties of path.matchesGlob have to be corrected, and both are
|
|
40
|
+
// corrected the same way: by rewriting the subject *and* the pattern through
|
|
41
|
+
// the same injective transform, so the built-in matcher does the actual glob
|
|
42
|
+
// work while the transform carries the semantics it will not.
|
|
43
|
+
//
|
|
44
|
+
// 1. Dot-blindness. A wildcard will not match a path segment starting with
|
|
45
|
+
// `.`, and there is no option to change that. Rewriting a segment's leading
|
|
46
|
+
// dot to a control character makes wildcards dot-permissive while keeping
|
|
47
|
+
// an explicitly-named segment literal: pattern `.git` becomes `<1>git`,
|
|
48
|
+
// which still matches a directory named `.git` and still does not match one
|
|
49
|
+
// named `git`. Only applied where dot-permissive matching is wanted.
|
|
50
|
+
// 2. Partial case-folding. The matcher folds case in some positions — e.g.
|
|
51
|
+
// `UPPER/MiXeD.JS` matches `**/*.js` — while `Test/a.js` does not match
|
|
52
|
+
// `**/test/**`. That inconsistency is worse than either rule on its own,
|
|
53
|
+
// and case-folding is not what the previous implementation did. Encoding
|
|
54
|
+
// each upper-case letter as a control character plus its lower-case form
|
|
55
|
+
// makes matching case-sensitive everywhere: no folded comparison can
|
|
56
|
+
// reintroduce the marker.
|
|
57
|
+
//
|
|
58
|
+
// Neither control character can appear in a filename produced by any of the
|
|
59
|
+
// filesystems this runs on, so the transform cannot collide with real content.
|
|
60
|
+
const DOT = String.fromCharCode(1);
|
|
61
|
+
const UP = String.fromCharCode(2);
|
|
62
|
+
const HAS_UPPER = /[A-Z]/;
|
|
63
|
+
const ALL_UPPER = /[A-Z]/g;
|
|
64
|
+
const foldUp = c => UP + c.toLowerCase();
|
|
65
|
+
|
|
66
|
+
function prepPath(p, dot) {
|
|
67
|
+
let s = p;
|
|
68
|
+
if (dot && s.includes('.')) {
|
|
69
|
+
const segs = s.split('/');
|
|
70
|
+
for (let i = 0; i < segs.length; i++) {
|
|
71
|
+
if (segs[i].charCodeAt(0) === 46) segs[i] = DOT + segs[i].slice(1);
|
|
72
|
+
}
|
|
73
|
+
s = segs.join('/');
|
|
74
|
+
}
|
|
75
|
+
return HAS_UPPER.test(s) ? s.replace(ALL_UPPER, foldUp) : s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* True when `rel` (a '/'-separated relative path) matches any of `patterns`.
|
|
80
|
+
* Case-sensitive; wildcards match hidden segments.
|
|
81
|
+
*/
|
|
82
|
+
export function matchesAnyGlob(rel, patterns) {
|
|
83
|
+
if (!patterns || patterns.length === 0) return false;
|
|
84
|
+
const subject = prepPath(rel, true);
|
|
85
|
+
for (const pat of patterns) {
|
|
86
|
+
if (path.matchesGlob(subject, prepPath(pat, true))) return true;
|
|
87
|
+
}
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// A directory may be skipped wholesale only when a pattern provably excludes
|
|
92
|
+
// every possible descendant. `<prefix>/**` is exactly that shape: `**` absorbs
|
|
93
|
+
// any number of trailing segments, so if the directory matches `<prefix>` then
|
|
94
|
+
// nothing beneath it can survive. Anything else (`<prefix>/*`, `**/*.min.js`)
|
|
95
|
+
// may exclude some descendants and keep others, so those directories are walked
|
|
96
|
+
// and their files filtered individually. Pruning is therefore only ever a
|
|
97
|
+
// speed-up: correctness comes from the per-file check.
|
|
98
|
+
function prunable(subject, prepared) {
|
|
99
|
+
for (const p of prepared) {
|
|
100
|
+
if (p.subtree && path.matchesGlob(subject, p.subtree)) return true;
|
|
101
|
+
}
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function prepare(patterns) {
|
|
106
|
+
return patterns.map(pat => {
|
|
107
|
+
const glob = prepPath(pat, true);
|
|
108
|
+
return { glob, subtree: glob.endsWith('/**') ? glob.slice(0, -3) : null };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Every regular file under `root`, as '/'-separated paths relative to `root`.
|
|
114
|
+
* Hidden entries included; directories and symlinks excluded; unreadable
|
|
115
|
+
* directories skipped; `ignore` globs applied to each file path.
|
|
116
|
+
*/
|
|
117
|
+
export async function listFiles(root, { ignore = [] } = {}) {
|
|
118
|
+
const prepared = prepare(ignore);
|
|
119
|
+
const out = [];
|
|
120
|
+
let level = [''];
|
|
121
|
+
while (level.length) {
|
|
122
|
+
const next = [];
|
|
123
|
+
for (const dir of level) {
|
|
124
|
+
let entries;
|
|
125
|
+
try {
|
|
126
|
+
entries = await fs.readdir(dir ? path.join(root, dir) : root, { withFileTypes: true });
|
|
127
|
+
} catch {
|
|
128
|
+
continue; // unreadable or vanished — skip it, keep walking
|
|
129
|
+
}
|
|
130
|
+
for (const e of entries) {
|
|
131
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
132
|
+
const subject = prepared.length ? prepPath(rel, true) : '';
|
|
133
|
+
if (e.isDirectory()) {
|
|
134
|
+
if (!prunable(subject, prepared)) next.push(rel);
|
|
135
|
+
} else if (e.isFile()) {
|
|
136
|
+
let skip = false;
|
|
137
|
+
for (const p of prepared) {
|
|
138
|
+
if (path.matchesGlob(subject, p.glob)) { skip = true; break; }
|
|
139
|
+
}
|
|
140
|
+
if (!skip) out.push(rel);
|
|
141
|
+
}
|
|
142
|
+
// symlinks, sockets, fifos and block/char devices are not files
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
level = next;
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve a user-supplied glob to regular files, relative to `cwd`. Hidden
|
|
152
|
+
* paths are not matched (the built-in default) and symlinks are followed, both
|
|
153
|
+
* matching the previous behaviour at these call sites.
|
|
154
|
+
*
|
|
155
|
+
* Two corrections are layered on the built-in walk: results are narrowed to
|
|
156
|
+
* regular files (a glob yields directories too), and the partial case-folding
|
|
157
|
+
* described above is undone by re-checking each result against the pattern
|
|
158
|
+
* case-sensitively — a `.SARIF` file must not answer to `*.sarif`. Symlink
|
|
159
|
+
* cycles are the one place this is deliberately not bug-compatible: the
|
|
160
|
+
* built-in stops at a cycle instead of re-enumerating the tree through it.
|
|
161
|
+
*/
|
|
162
|
+
export async function globFiles(pattern, { cwd = process.cwd() } = {}) {
|
|
163
|
+
const strict = prepPath(pattern, false);
|
|
164
|
+
const out = [];
|
|
165
|
+
for await (const p of fs.glob(pattern, { cwd })) {
|
|
166
|
+
if (!path.matchesGlob(prepPath(p, false), strict)) continue;
|
|
167
|
+
try {
|
|
168
|
+
const st = await fs.stat(path.isAbsolute(p) ? p : path.join(cwd, p));
|
|
169
|
+
if (st.isFile()) out.push(p);
|
|
170
|
+
} catch { /* vanished or dangling link — not a file */ }
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
package/src/util/yaml.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Thin wrapper over js-yaml.
|
|
2
|
+
//
|
|
3
|
+
// js-yaml 5 throws on empty input ("expected a document, but the input is
|
|
4
|
+
// empty") where 4.x returned undefined. Every YAML file this scanner reads is
|
|
5
|
+
// user-authored config — .agentic-security/rules.yml, suppressions.yml,
|
|
6
|
+
// profiles, policies — and an empty or fully commented-out config is a normal,
|
|
7
|
+
// intentional state, not an error. Without this shim a blank rules.yml prints a
|
|
8
|
+
// spurious parse error on every scan.
|
|
9
|
+
//
|
|
10
|
+
// Import this instead of js-yaml directly anywhere config is read.
|
|
11
|
+
import * as _yaml from 'js-yaml';
|
|
12
|
+
|
|
13
|
+
/** Parse YAML, returning undefined for blank/comment-only input (4.x behaviour). */
|
|
14
|
+
export function load(text, opts) {
|
|
15
|
+
if (typeof text !== 'string') return undefined;
|
|
16
|
+
// A document that is only whitespace and/or comments has no content. js-yaml
|
|
17
|
+
// 4 returned undefined here; preserve that rather than surfacing a throw.
|
|
18
|
+
const stripped = text.replace(/^\s*#.*$/gm, '').trim();
|
|
19
|
+
if (stripped === '') return undefined;
|
|
20
|
+
return _yaml.load(text, opts);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const dump = _yaml.dump;
|
|
24
|
+
export const CORE_SCHEMA = _yaml.CORE_SCHEMA;
|