@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.28
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 +14 -0
- package/bin/agentic-security.js +35 -4
- package/dist/{90ebba38d8860ec77376.json → 3eabd90d6d9c8c8dbbd9.json} +4 -3
- package/dist/5856.index.js +634 -0
- package/dist/7033.index.js +74 -14
- package/dist/9220.index.js +24 -1
- package/dist/agentic-security.mjs +9 -9
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -3
- package/src/dataflow/catalog.js +42 -1
- package/src/engine.js +90 -10
- package/src/language/CLAUDE.md +6 -0
- package/src/language/assurance.js +53 -2
- package/src/language/haskell-budget-state.js +85 -0
- package/src/language/haskell-coverage.js +230 -0
- package/src/language/haskell-project-models.js +107 -0
- package/src/language/nix-eval-isolation.js +74 -14
- package/src/llm-validator/bounded-context.js +87 -0
- package/src/llm-validator/claim-groups.js +105 -0
- package/src/llm-validator/disposition.js +264 -0
- package/src/llm-validator/index.js +52 -26
- package/src/pipeline/evidence-record.js +112 -0
- package/src/pipeline/scan-health.js +12 -1
- package/src/posture/artifact-registry.js +2 -0
- package/src/posture/drift.js +19 -1
- package/src/posture/reachability-filter.js +3 -3
- package/src/posture/suppressions.js +21 -0
- package/src/posture/watch-mode.js +22 -1
- package/src/report/disposition-view.js +90 -0
- package/src/report/index.js +47 -0
- package/src/runScan.js +60 -6
- package/src/sast/oidc-trust.js +74 -0
- package/src/sast/pipeline.js +14 -1
- package/src/sast/workflow-flow.js +272 -0
- package/src/sca/basis.js +88 -0
- package/src/scope/flags.js +55 -0
- package/src/scope/inventory.js +241 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Scan inventory (PRD FR 01): one eligibility decision per candidate path,
|
|
2
|
+
// made BEFORE any file content is read, with a recorded reason for every skip.
|
|
3
|
+
//
|
|
4
|
+
// Git decides what Git ignores. Ignore semantics are never reimplemented here:
|
|
5
|
+
// `git ls-files -z` handles nested .gitignore files, negation, anchoring,
|
|
6
|
+
// directory patterns, escaping and unusual filenames. This module only layers
|
|
7
|
+
// the scanner's own policy on top, in the PRD's order:
|
|
8
|
+
//
|
|
9
|
+
// 1. root and filesystem safety (no symlinks, nothing outside the root)
|
|
10
|
+
// 2. explicit scanner exclusion (excludePaths)
|
|
11
|
+
// 3. exact explicit inclusion (includePaths, includeIgnoredPaths)
|
|
12
|
+
// 4. Git selection (tracked + untracked-not-ignored)
|
|
13
|
+
// 5. narrowly defined scanner defaults (build-output directory SEGMENTS)
|
|
14
|
+
//
|
|
15
|
+
// A tracked file stays eligible even when a later ignore pattern matches it.
|
|
16
|
+
// Defaults match whole path segments, never substrings: `.github/workflows/
|
|
17
|
+
// buck2.yml` and `tools/buck2/` are not build output.
|
|
18
|
+
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import * as crypto from 'node:crypto';
|
|
22
|
+
import * as cp from 'node:child_process';
|
|
23
|
+
import { hardenGitArgs, hardenGitEnv } from '../util/git-hardening.js';
|
|
24
|
+
|
|
25
|
+
/** Generated-output directory names, matched as exact path segments. */
|
|
26
|
+
export const GENERATED_DIR_SEGMENTS = Object.freeze([
|
|
27
|
+
'buck-out', 'dist-newstyle', '.stack-work', '.cabal-sandbox',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const SCOPE_MODES = Object.freeze({ LOCAL: 'local', REPOSITORY: 'repository' });
|
|
31
|
+
|
|
32
|
+
function git(root, args, { env = {}, maxBuffer = 256 * 1024 * 1024 } = {}) {
|
|
33
|
+
const r = cp.spawnSync('git', hardenGitArgs(args), {
|
|
34
|
+
cwd: root, env: hardenGitEnv(env), maxBuffer, encoding: 'buffer',
|
|
35
|
+
});
|
|
36
|
+
return r.status === 0 ? r.stdout : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function nulList(buf) {
|
|
40
|
+
return buf.toString('utf8').split('\0').filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function norm(p) {
|
|
44
|
+
return p.split(path.sep).join('/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function segmentOf(rel, segments) {
|
|
48
|
+
for (const s of rel.split('/')) if (segments.includes(s)) return s;
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function sha(s) {
|
|
53
|
+
return crypto.createHash('sha256').update(s).digest('hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function digestRules(root, mode) {
|
|
57
|
+
// Digest of every ignore source that can change the selection. Hashing the
|
|
58
|
+
// rule files (not the selected set) lets a rule edit invalidate scope caches.
|
|
59
|
+
const sources = [];
|
|
60
|
+
const listed = git(root, ['ls-files', '-z', '--cached', '--others', '--exclude-standard']);
|
|
61
|
+
const files = (listed ? nulList(listed) : []).filter((f) => path.posix.basename(f) === '.gitignore').sort();
|
|
62
|
+
for (const f of files) {
|
|
63
|
+
try { sources.push(`${f}\0${fs.readFileSync(path.join(root, f), 'utf8')}`); } catch { /* vanished */ }
|
|
64
|
+
}
|
|
65
|
+
if (mode === SCOPE_MODES.LOCAL) {
|
|
66
|
+
for (const f of [path.join('.git', 'info', 'exclude')]) {
|
|
67
|
+
try { sources.push(`${f}\0${fs.readFileSync(path.join(root, f), 'utf8')}`); } catch { /* absent */ }
|
|
68
|
+
}
|
|
69
|
+
const gx = git(root, ['config', '--get', 'core.excludesFile']);
|
|
70
|
+
if (gx) {
|
|
71
|
+
const p = gx.toString('utf8').trim().replace(/^~(?=\/)/, process.env.HOME || '~');
|
|
72
|
+
try { sources.push(`global\0${fs.readFileSync(p, 'utf8')}`); } catch { /* absent */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return sha(sources.join('\n--\n'));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isGitWorkTree(root) {
|
|
79
|
+
const out = git(root, ['rev-parse', '--is-inside-work-tree']);
|
|
80
|
+
return !!out && out.toString('utf8').trim() === 'true';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ask Git for the selected file set and the ignored subtrees, without reading
|
|
85
|
+
* any file content and without enumerating inside an ignored directory.
|
|
86
|
+
*/
|
|
87
|
+
function gitSelection(root, mode) {
|
|
88
|
+
const env = mode === SCOPE_MODES.REPOSITORY ? { GIT_CONFIG_GLOBAL: '/dev/null' } : {};
|
|
89
|
+
// Repository mode: honour only .gitignore files in the tree. --exclude-standard
|
|
90
|
+
// would add .git/info/exclude and the global file, which differ between
|
|
91
|
+
// machines, so name the per-directory source explicitly instead.
|
|
92
|
+
const ignoreArgs = mode === SCOPE_MODES.REPOSITORY ? ['--exclude-per-directory=.gitignore'] : ['--exclude-standard'];
|
|
93
|
+
const cands = git(root, ['ls-files', '-z', '--cached', '--others', ...ignoreArgs], { env });
|
|
94
|
+
if (!cands) return null;
|
|
95
|
+
// --directory collapses an ignored directory to one entry instead of walking it.
|
|
96
|
+
const ign = git(root, ['ls-files', '-z', '--others', '--ignored', '--directory', ...ignoreArgs], { env });
|
|
97
|
+
const tracked = git(root, ['ls-files', '-z', '--cached'], { env });
|
|
98
|
+
// Gitlinks (submodules) are index entries with mode 160000.
|
|
99
|
+
const staged = git(root, ['ls-files', '-z', '--stage'], { env });
|
|
100
|
+
const gitlinks = new Set((staged ? nulList(staged) : []).filter((l) => l.startsWith('160000 ')).map((l) => norm(l.slice(l.indexOf('\t') + 1))));
|
|
101
|
+
return {
|
|
102
|
+
gitlinks,
|
|
103
|
+
candidates: nulList(cands).map(norm),
|
|
104
|
+
ignored: ign ? nulList(ign).map((d) => d.replace(/^\.\//, '')) : [],
|
|
105
|
+
tracked: new Set(tracked ? nulList(tracked).map(norm) : []),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unitKind(abs, isGitlink) {
|
|
110
|
+
if (isGitlink) return 'submodule';
|
|
111
|
+
try { if (fs.lstatSync(abs).isDirectory() && fs.existsSync(path.join(abs, '.git'))) return 'nested-repository'; } catch { /* gone */ }
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function lstatKind(abs) {
|
|
116
|
+
try {
|
|
117
|
+
const st = fs.lstatSync(abs);
|
|
118
|
+
if (st.isSymbolicLink()) return 'symlink';
|
|
119
|
+
if (st.isFile()) return 'file';
|
|
120
|
+
return 'other';
|
|
121
|
+
} catch { return 'missing'; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function underRoot(root, abs) {
|
|
125
|
+
let real;
|
|
126
|
+
try { real = fs.realpathSync(abs); } catch { return false; }
|
|
127
|
+
const r = fs.realpathSync(root);
|
|
128
|
+
return real === r || real.startsWith(r + path.sep);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the inventory. Never reads file contents.
|
|
133
|
+
*
|
|
134
|
+
* @param {string} root
|
|
135
|
+
* @param {object} [o]
|
|
136
|
+
* @param {boolean} [o.respectGitignore=true]
|
|
137
|
+
* @param {'local'|'repository'} [o.ignoreSource='local']
|
|
138
|
+
* @param {string[]} [o.includePaths] exact paths; beat Git ignore and default build exclusions
|
|
139
|
+
* @param {string[]} [o.includeIgnoredPaths] exact ignored files, opted in explicitly
|
|
140
|
+
* @param {string[]} [o.excludePaths] path or directory prefixes; beat every inclusion
|
|
141
|
+
* @param {string[]} [o.generatedSegments]
|
|
142
|
+
* @returns {{included: string[], skipped: object[], overrides: object[], policy: object}}
|
|
143
|
+
*/
|
|
144
|
+
export function buildScanInventory(root, o = {}) {
|
|
145
|
+
const {
|
|
146
|
+
respectGitignore = true, ignoreSource = SCOPE_MODES.LOCAL,
|
|
147
|
+
includePaths = [], includeIgnoredPaths = [], excludePaths = [],
|
|
148
|
+
generatedSegments = GENERATED_DIR_SEGMENTS,
|
|
149
|
+
} = o;
|
|
150
|
+
const mode = ignoreSource === SCOPE_MODES.REPOSITORY ? SCOPE_MODES.REPOSITORY : SCOPE_MODES.LOCAL;
|
|
151
|
+
const included = [];
|
|
152
|
+
const skipped = [];
|
|
153
|
+
const overrides = [];
|
|
154
|
+
const exclude = excludePaths.map(norm);
|
|
155
|
+
const include = new Set([...includePaths, ...includeIgnoredPaths].map(norm));
|
|
156
|
+
const ignoredInclude = new Set(includeIgnoredPaths.map(norm));
|
|
157
|
+
|
|
158
|
+
const sel = respectGitignore && isGitWorkTree(root) ? gitSelection(root, mode) : null;
|
|
159
|
+
const policy = {
|
|
160
|
+
mode: sel ? mode : 'walk',
|
|
161
|
+
respectGitignore,
|
|
162
|
+
trackedFileSemantics: !!sel,
|
|
163
|
+
ruleDigest: sel ? digestRules(root, mode) : null,
|
|
164
|
+
disclosures: sel ? [] : [
|
|
165
|
+
respectGitignore
|
|
166
|
+
? 'not a Git work tree: .gitignore rules are not applied and tracked-file semantics are unavailable'
|
|
167
|
+
: 'Git ignore rules disabled by explicit option',
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
let candidates;
|
|
172
|
+
if (sel) {
|
|
173
|
+
candidates = sel.candidates;
|
|
174
|
+
for (const d of sel.ignored) {
|
|
175
|
+
// Reported once per collapsed subtree, never enumerated.
|
|
176
|
+
skipped.push({ path: d, reason: 'git-ignored', rule: d.endsWith('/') ? 'directory' : 'file' });
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
candidates = walkAll(root);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const seen = new Set();
|
|
183
|
+
for (const rel of candidates) {
|
|
184
|
+
if (seen.has(rel)) continue;
|
|
185
|
+
seen.add(rel);
|
|
186
|
+
const abs = path.join(root, rel);
|
|
187
|
+
const kind = lstatKind(abs);
|
|
188
|
+
if (kind === 'missing') { skipped.push({ path: rel, reason: 'missing-tracked-file', coverageCondition: true }); continue; }
|
|
189
|
+
if (kind === 'symlink') { skipped.push({ path: rel, reason: 'symlink' }); continue; }
|
|
190
|
+
if (kind === 'other') {
|
|
191
|
+
// A directory that Git lists as one entry is a submodule or a nested repository: a separate scan unit that was not scanned.
|
|
192
|
+
const unit = unitKind(abs, sel && sel.gitlinks.has(rel));
|
|
193
|
+
skipped.push({ path: rel, reason: unit || 'unsupported-entry', coverageCondition: true, ...(unit ? { note: 'separate repository: declare it as its own scan target to cover it' } : {}) });
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (!underRoot(root, abs)) { skipped.push({ path: rel, reason: 'outside-root' }); continue; }
|
|
197
|
+
if (exclude.some((e) => rel === e || rel.startsWith(e + '/'))) { skipped.push({ path: rel, reason: 'explicit-exclude' }); continue; }
|
|
198
|
+
const seg = segmentOf(rel, generatedSegments);
|
|
199
|
+
if (seg && !include.has(rel)) {
|
|
200
|
+
// Tracked files do not override a generated-output default; only an exact inclusion does.
|
|
201
|
+
skipped.push({ path: rel, reason: 'generated-default', segment: seg });
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (seg && include.has(rel)) overrides.push({ path: rel, overrode: 'generated-default', segment: seg });
|
|
205
|
+
included.push(rel);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Exact inclusions of files Git ignored (or that the walk never produced).
|
|
209
|
+
for (const rel of include) {
|
|
210
|
+
if (seen.has(rel)) continue;
|
|
211
|
+
if (exclude.some((e) => rel === e || rel.startsWith(e + '/'))) { skipped.push({ path: rel, reason: 'explicit-exclude' }); continue; }
|
|
212
|
+
const abs = path.join(root, rel);
|
|
213
|
+
const kind = lstatKind(abs);
|
|
214
|
+
if (kind !== 'file' || !underRoot(root, abs) || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
215
|
+
skipped.push({ path: rel, reason: kind === 'symlink' ? 'symlink' : 'include-rejected', coverageCondition: true });
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
included.push(rel);
|
|
219
|
+
overrides.push({ path: rel, overrode: ignoredInclude.has(rel) ? 'git-ignore' : 'selection', evidenceOnly: false });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
included.sort();
|
|
223
|
+
return { included, skipped, overrides, policy };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function walkAll(root) {
|
|
227
|
+
const out = [];
|
|
228
|
+
const stack = [''];
|
|
229
|
+
while (stack.length) {
|
|
230
|
+
const dir = stack.pop();
|
|
231
|
+
let entries;
|
|
232
|
+
try { entries = fs.readdirSync(dir ? path.join(root, dir) : root, { withFileTypes: true }); } catch { continue; }
|
|
233
|
+
for (const e of entries) {
|
|
234
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
235
|
+
if (e.name === '.git') continue;
|
|
236
|
+
if (e.isDirectory()) stack.push(rel);
|
|
237
|
+
else out.push(rel);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|