@clear-capabilities/agentic-security-scanner 0.195.27 → 0.195.29

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/bin/agentic-security.js +41 -4
  3. package/dist/5856.index.js +634 -0
  4. package/dist/7033.index.js +74 -14
  5. package/dist/9220.index.js +24 -1
  6. package/dist/agentic-security.mjs +9 -9
  7. package/dist/agentic-security.mjs.sha256 +1 -1
  8. package/dist/{90ebba38d8860ec77376.json → c06a1b4c20155c06fb48.json} +4 -3
  9. package/package.json +4 -3
  10. package/src/dataflow/catalog.js +42 -1
  11. package/src/engine.js +91 -11
  12. package/src/language/CLAUDE.md +7 -1
  13. package/src/language/assurance.js +53 -2
  14. package/src/language/haskell-budget-state.js +85 -0
  15. package/src/language/haskell-coverage.js +230 -0
  16. package/src/language/haskell-guards.js +26 -5
  17. package/src/language/haskell-project-models.js +107 -0
  18. package/src/language/nix-eval-isolation.js +74 -14
  19. package/src/llm-validator/bounded-context.js +193 -0
  20. package/src/llm-validator/claim-groups.js +105 -0
  21. package/src/llm-validator/disposition.js +264 -0
  22. package/src/llm-validator/index.js +52 -26
  23. package/src/pipeline/evidence-record.js +112 -0
  24. package/src/pipeline/scan-health.js +12 -1
  25. package/src/posture/artifact-registry.js +2 -0
  26. package/src/posture/drift.js +19 -1
  27. package/src/posture/reachability-filter.js +3 -3
  28. package/src/posture/suppressions.js +21 -0
  29. package/src/posture/watch-mode.js +22 -1
  30. package/src/report/disposition-view.js +90 -0
  31. package/src/report/index.js +47 -0
  32. package/src/runScan.js +60 -6
  33. package/src/sast/oidc-trust.js +74 -0
  34. package/src/sast/pipeline.js +14 -1
  35. package/src/sast/workflow-flow.js +272 -0
  36. package/src/sca/basis.js +88 -0
  37. package/src/scope/flags.js +55 -0
  38. package/src/scope/inventory.js +241 -0
@@ -0,0 +1,272 @@
1
+ // YAML-aware GitHub Actions analysis (PRD FR 06).
2
+ //
3
+ // Replaces context-free line matching for two rules with structural analysis:
4
+ // * template injection: is an untrusted value substituted into executable
5
+ // script text, passed as data through env, or later evaluated as code?
6
+ // * OIDC: `id-token: write` is read from the PARSED permissions map (a
7
+ // commented example is not a grant), and is reported as "trust policy not
8
+ // verifiable from the repository", never as a proven missing restriction.
9
+ //
10
+ // Pure: (file path, raw text) in, findings out. Returns null when the file is
11
+ // not parseable workflow YAML so the caller can fall back to the line rules.
12
+ // Nothing here executes workflow content.
13
+
14
+ import { load } from '../util/yaml.js';
15
+
16
+ // Event fields an outsider controls. Dispatch/reusable inputs are handled
17
+ // separately: their author's privilege is assessed, and a `choice` input with
18
+ // declared options is constrained.
19
+ const UNTRUSTED_EVENT = [
20
+ /^github\.event\.(?:issue|pull_request|discussion)\.(?:title|body)$/,
21
+ /^github\.event\.(?:comment|review|review_comment)\.body$/,
22
+ /^github\.event\.pull_request\.head\.(?:ref|label|repo\.default_branch)$/,
23
+ /^github\.event\.(?:head_commit|commits\.\*|commits\[\d+\])\.(?:message|author\.(?:name|email))$/,
24
+ /^github\.event\.head_commit\.(?:message|author\.(?:name|email))$/,
25
+ /^github\.event\.workflow_run\.(?:head_branch|head_commit\.message|display_title)$/,
26
+ /^github\.head_ref$/,
27
+ ];
28
+ const INPUT_EXPR = /^(?:github\.event\.inputs|inputs)\.([A-Za-z_][\w-]*)$/;
29
+ const EXPR_RE = /\$\{\{\s*([^}]*?)\s*\}\}/g;
30
+
31
+ // Second interpretation of an env-carried value: the shell re-parses its content as code.
32
+ const EVAL_SINKS = (v) => [
33
+ new RegExp(`\\beval\\b[^\\n;|&]*\\$\\{?${v}\\b`),
34
+ new RegExp(`\\b(?:ba|z|da)?sh\\s+-c\\s+["']?[^\\n]*\\$\\{?${v}\\b`),
35
+ new RegExp(`\\bsource\\s+<\\(\\s*echo\\s+[^)]*\\$\\{?${v}\\b`),
36
+ new RegExp(`\\bInvoke-Expression\\b[^\\n]*\\$(?:env:)?${v}\\b`, 'i'),
37
+ new RegExp(`\\biex\\b[^\\n]*\\$(?:env:)?${v}\\b`, 'i'),
38
+ new RegExp(`\\bpwsh\\s+-(?:c|command)\\b[^\\n]*\\$(?:env:)?${v}\\b`, 'i'),
39
+ ];
40
+
41
+ const AGENT_ACTION = /(?:^|\/)(?:claude-code|claude|codex|copilot|gemini|openhands|aider|swe-agent)[\w-]*(?:-action|-agent)?@/i;
42
+ const UNTRUSTED_TRIGGERS = ['issues', 'issue_comment', 'pull_request_target', 'pull_request_review', 'pull_request_review_comment', 'discussion', 'discussion_comment'];
43
+
44
+ function isUntrustedEvent(expr) { return UNTRUSTED_EVENT.some((re) => re.test(expr)); }
45
+
46
+ function untrustedIn(text, inputs) {
47
+ const out = [];
48
+ if (typeof text !== 'string') return out;
49
+ for (const m of text.matchAll(EXPR_RE)) {
50
+ const expr = m[1].trim();
51
+ if (isUntrustedEvent(expr)) { out.push({ expr, origin: expr, kind: 'event-field' }); continue; }
52
+ const im = INPUT_EXPR.exec(expr);
53
+ if (im) {
54
+ const decl = inputs[im[1]];
55
+ // A choice input with declared options is constrained to those literals.
56
+ if (decl && decl.type === 'choice' && Array.isArray(decl.options) && decl.options.every((o) => /^[\w.\- ]*$/.test(String(o)))) continue;
57
+ out.push({ expr, origin: expr, kind: 'dispatch-input', input: im[1] });
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function collectInputs(doc) {
64
+ const on = doc.on ?? doc[true]; // YAML 1.1 reads a bare `on` key as boolean true
65
+ const inputs = {};
66
+ if (on && typeof on === 'object') {
67
+ for (const ev of ['workflow_dispatch', 'workflow_call']) {
68
+ const i = on[ev]?.inputs;
69
+ if (i && typeof i === 'object') Object.assign(inputs, i);
70
+ }
71
+ }
72
+ return { on, inputs };
73
+ }
74
+
75
+ function triggers(on) {
76
+ if (typeof on === 'string') return [on];
77
+ if (Array.isArray(on)) return on.map(String);
78
+ if (on && typeof on === 'object') return Object.keys(on);
79
+ return [];
80
+ }
81
+
82
+ function lineOf(raw, needle, fromLine = 0) {
83
+ const lines = raw.split('\n');
84
+ for (let i = fromLine; i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
85
+ for (let i = 0; i < fromLine && i < lines.length; i++) if (lines[i].includes(needle)) return i + 1;
86
+ return 1;
87
+ }
88
+
89
+ function permissionsOf(p) {
90
+ if (p === 'write-all') return { all: 'write' };
91
+ return p && typeof p === 'object' ? p : {};
92
+ }
93
+
94
+ /** Remove shell comment lines so a commented example is not read as a sink. */
95
+ function stripShellComments(script, shell) {
96
+ const hash = /^\s*#/;
97
+ return String(script).split('\n').filter((l) => !hash.test(l)).join('\n');
98
+ }
99
+
100
+ /** A validation guard that exits before the value is used, e.g. [[ "$X" =~ ^[a-z]+$ ]] || exit 1. */
101
+ function validatedBeforeUse(script, v, useIdx) {
102
+ const lines = script.split('\n');
103
+ for (let i = 0; i < lines.length && i < useIdx; i++) {
104
+ const l = lines[i];
105
+ if (new RegExp(`\\$\\{?${v}\\b`).test(l) && /(?:=~|\bgrep\s+-[A-Za-z]*q|\bcase\b)/.test(l) && /(?:exit\s+\d|return\s+\d|\bthen\b[^\n]*exit|\|\|\s*exit)/.test(l)) return true;
106
+ if (new RegExp(`\\$\\{?${v}\\b`).test(l) && /-notmatch|-match/.test(l) && /exit/i.test(l)) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ export function analyzeWorkflow(fp, raw) {
112
+ let doc;
113
+ // js-yaml is quadratic on a long run of blank lines. A run that long carries no meaning, so collapse it for parsing only;
114
+ // lines are located in `raw`, not in the collapsed text.
115
+ try { doc = load(raw.replace(/(?:[ \t]*\r?\n){20,}/g, '\n')); } catch { return null; }
116
+ if (!doc || typeof doc !== 'object') return null;
117
+ // A composite action (`runs.using: composite`) has steps but no jobs; its `inputs.*` come from whichever workflow calls it.
118
+ const composite = !doc.jobs && doc.runs && doc.runs.using === 'composite' && Array.isArray(doc.runs.steps);
119
+ if (!composite && typeof doc.jobs !== 'object') return null;
120
+ const { on, inputs } = composite ? { on: {}, inputs: (doc.inputs && typeof doc.inputs === 'object') ? doc.inputs : {} } : collectInputs(doc);
121
+ const trig = triggers(on);
122
+ const wfPerm = permissionsOf(doc.permissions);
123
+ const wfEnv = doc.env && typeof doc.env === 'object' ? doc.env : {};
124
+ const findings = [];
125
+ const seen = new Set();
126
+ const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
127
+
128
+ const privilegedTrigger = trig.includes('pull_request_target') || trig.includes('workflow_run');
129
+ const dispatch = trig.includes('workflow_dispatch') || trig.includes('workflow_call');
130
+
131
+ const jobEntries = composite ? [['(composite action)', { steps: doc.runs.steps }]] : Object.entries(doc.jobs || {});
132
+ for (const [jobId, job] of jobEntries) {
133
+ if (!job || typeof job !== 'object') continue;
134
+ const jobPerm = job.permissions !== undefined ? permissionsOf(job.permissions) : wfPerm;
135
+ const jobEnv = { ...wfEnv, ...(job.env && typeof job.env === 'object' ? job.env : {}) };
136
+ const writes = Object.entries(jobPerm).filter(([, v]) => v === 'write').map(([k]) => k);
137
+ const steps = Array.isArray(job.steps) ? job.steps : [];
138
+
139
+ if (jobPerm['id-token'] === 'write') {
140
+ const line = lineOf(raw, 'id-token');
141
+ push({
142
+ id: `pipeline:${fp}:${line}:oidc:${jobId}`, kind: 'iac', severity: 'low',
143
+ vuln: 'Pipeline: OIDC id-token: write, cloud trust policy and audience not verifiable from the repository',
144
+ cwe: 'CWE-1188', stride: 'Elevation of privilege', file: fp, line, snippet: 'id-token: write',
145
+ fix: 'Confirm the cloud-side trust policy requires this repository, a specific audience and a restricted subject (branch or environment). If no step requests a token, remove the permission.',
146
+ workflow: fp, job: jobId, step: null, disposition: 'needs-review',
147
+ missingEvidence: ['cloud trust policy (audience and subject conditions)', 'whether any action in this job requests an OIDC token'],
148
+ sensitiveOperation: 'OIDC token minting', privilegeBoundary: `job permissions: ${writes.join(', ') || 'none'}`,
149
+ });
150
+ }
151
+
152
+ steps.forEach((step, idx) => {
153
+ if (!step || typeof step !== 'object') return;
154
+ const stepName = step.name || step.id || `step-${idx + 1}`;
155
+ const stepEnv = { ...jobEnv, ...(step.env && typeof step.env === 'object' ? step.env : {}) };
156
+ const shell = String(step.shell || job.defaults?.run?.shell || doc.defaults?.run?.shell || 'bash');
157
+
158
+ // Untrusted code checked out under a privileged trigger.
159
+ if (typeof step.uses === 'string' && /^actions\/checkout@/.test(step.uses) && privilegedTrigger) {
160
+ const ref = step.with?.ref;
161
+ if (typeof ref === 'string' && /github\.(?:event\.pull_request\.head\.(?:sha|ref)|head_ref)|workflow_run\.head_(?:sha|branch)/.test(ref)) {
162
+ const line = lineOf(raw, String(ref).trim());
163
+ push({
164
+ id: `pipeline:${fp}:${line}:untrusted-checkout`, kind: 'iac', severity: 'high',
165
+ vuln: 'Pipeline: pull_request_target checks out untrusted pull request code',
166
+ cwe: 'CWE-829', stride: 'Tampering', file: fp, line, snippet: `ref: ${ref}`,
167
+ fix: 'A privileged-trigger workflow runs with repository secrets. Do not check out and build the pull request head in it; use `pull_request`, or split the privileged step.',
168
+ workflow: fp, job: jobId, step: stepName, disposition: 'needs-review',
169
+ inputOrigin: ref, sensitiveOperation: 'checkout of attacker-controlled code', privilegeBoundary: `trigger ${trig.join(', ')}; permissions: ${writes.join(', ') || 'default'}`,
170
+ });
171
+ }
172
+ }
173
+
174
+ // Remote and unknown actions: a value the analysis cannot follow into the action.
175
+ if (typeof step.uses === 'string' && !step.uses.startsWith('./') && step.with && typeof step.with === 'object') {
176
+ for (const [wk, wv] of Object.entries(step.with)) {
177
+ for (const u of untrustedIn(String(wv), inputs)) {
178
+ const line = lineOf(raw, String(wv).trim().split('\n')[0].slice(0, 40));
179
+ const scriptInput = /^actions\/github-script@/.test(step.uses) && wk === 'script';
180
+ push({
181
+ id: `pipeline:${fp}:${line}:action-input:${jobId}:${idx}:${wk}:${u.expr}`, kind: 'iac',
182
+ severity: scriptInput && u.kind === 'event-field' ? 'critical' : 'low',
183
+ vuln: scriptInput ? 'Pipeline: untrusted github.event input interpolated into github-script source' : 'Pipeline: untrusted input passed to an action this analysis cannot inspect',
184
+ cwe: scriptInput ? 'CWE-94' : 'CWE-20', stride: 'Tampering', file: fp, line, snippet: `${wk}: ${String(wv).trim().split('\n')[0].slice(0, 80)}`,
185
+ fix: scriptInput ? 'Pass the value through `env:` and read it as data (process.env.NAME); never substitute it into the script text.' : 'Confirm how the action uses this input (shell, script or URL) and pin the action to a commit.',
186
+ workflow: fp, job: jobId, step: stepName, inputOrigin: u.origin, inputOriginKind: u.kind,
187
+ sensitiveOperation: scriptInput ? 'JavaScript executed by github-script' : `input "${wk}" of ${step.uses}`,
188
+ privilegeBoundary: `trigger ${trig.join(', ') || 'composite action (caller decides)'}; permissions: ${writes.join(', ') || 'default'}`,
189
+ disposition: scriptInput && u.kind === 'event-field' ? 'detected' : 'needs-review',
190
+ ...(scriptInput && u.kind === 'event-field' ? {} : { missingEvidence: [`source and handling of ${step.uses.split('@')[0]}`] }),
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ // Artifacts produced by an untrusted run are attacker-controlled data once a privileged workflow_run consumes them.
197
+ if (typeof step.uses === 'string' && /^actions\/download-artifact@/.test(step.uses) && trig.includes('workflow_run')) {
198
+ const line = lineOf(raw, 'download-artifact');
199
+ push({
200
+ id: `pipeline:${fp}:${line}:artifact:${jobId}:${idx}`, kind: 'iac', severity: writes.length ? 'medium' : 'low',
201
+ vuln: 'Pipeline: workflow_run consumes an artifact built by the triggering run',
202
+ cwe: 'CWE-829', stride: 'Tampering', file: fp, line, snippet: step.uses,
203
+ fix: 'Treat artifact content as untrusted data: validate it before use and never execute or source it in a job with write permissions or secrets.',
204
+ workflow: fp, job: jobId, step: stepName, inputOrigin: 'artifact from the triggering workflow run', inputOriginKind: 'artifact',
205
+ sensitiveOperation: 'use of artifact contents in a privileged workflow', privilegeBoundary: `trigger workflow_run; permissions: ${writes.join(', ') || 'default'}`,
206
+ disposition: 'needs-review', missingEvidence: ['whether the triggering run can be started from a fork', 'how the artifact contents are used after download'],
207
+ });
208
+ }
209
+
210
+ // Agent scenario: model-driven action with write authority and attacker-supplied prompt content.
211
+ if (typeof step.uses === 'string' && AGENT_ACTION.test(step.uses) && writes.length && trig.some((t) => UNTRUSTED_TRIGGERS.includes(t))) {
212
+ const line = lineOf(raw, step.uses.slice(0, 30));
213
+ push({
214
+ id: `pipeline:${fp}:${line}:agent:${jobId}:${idx}`, kind: 'iac', severity: 'medium',
215
+ vuln: 'Pipeline: AI agent with write permissions runs on attacker-supplied content',
216
+ cwe: 'CWE-77', stride: 'Elevation of privilege', file: fp, line, snippet: step.uses,
217
+ fix: 'Restrict the agent to read-only permissions, require an approval before it can write, or run it only for trusted authors. Prompt content from issues, comments and pull requests is untrusted.',
218
+ workflow: fp, job: jobId, step: stepName, inputOrigin: `event content from ${trig.filter((t) => UNTRUSTED_TRIGGERS.includes(t)).join(', ')}`, inputOriginKind: 'agent-prompt',
219
+ sensitiveOperation: `agent actions with ${writes.join(', ')} write authority`, privilegeBoundary: `permissions: ${writes.join(', ')}`,
220
+ disposition: 'needs-review', missingEvidence: ['which authors can trigger the workflow', 'whether the agent has an approval step or tool restrictions'],
221
+ });
222
+ }
223
+
224
+ if (typeof step.run !== 'string') return;
225
+ const script = stripShellComments(step.run, shell);
226
+
227
+ // 1. Untrusted expression substituted directly into the script text.
228
+ for (const u of untrustedIn(script, inputs)) {
229
+ const line = lineOf(raw, '${{', lineOf(raw, step.run.split('\n')[0].trim().slice(0, 40)) - 1);
230
+ const privileged = privilegedTrigger || writes.length > 0 || Object.keys(job.secrets || {}).length > 0 || /secrets\./.test(JSON.stringify(step));
231
+ push({
232
+ id: `pipeline:${fp}:${line}:script:${jobId}:${idx}:${u.expr}`, kind: 'iac',
233
+ severity: u.kind === 'dispatch-input' && !privilegedTrigger ? 'medium' : 'critical',
234
+ vuln: 'Pipeline: untrusted github.event input interpolated into shell context',
235
+ cwe: 'CWE-78', stride: 'Tampering', file: fp, line, snippet: `\${{ ${u.expr} }}`,
236
+ fix: 'Pass the value through an environment variable and reference it as data ("$VAR" / $env:VAR); never substitute it into script text.',
237
+ workflow: fp, job: jobId, step: stepName, inputOrigin: u.origin,
238
+ inputOriginKind: u.kind, sensitiveOperation: `shell script (${shell})`,
239
+ privilegeBoundary: `trigger ${trig.join(', ')}; ${privileged ? 'job has write permissions or secrets' : 'job is read-only'}`,
240
+ disposition: u.kind === 'dispatch-input' ? 'needs-review' : 'detected',
241
+ ...(u.kind === 'dispatch-input' ? { missingEvidence: ['privilege of the dispatch input author relative to the job'] } : {}),
242
+ });
243
+ }
244
+
245
+ // 2. env-carried untrusted value, then re-interpreted as code.
246
+ for (const [name, val] of Object.entries(stepEnv)) {
247
+ const us = untrustedIn(String(val), inputs);
248
+ if (!us.length || !/^[A-Za-z_]\w*$/.test(name)) continue;
249
+ const sinks = EVAL_SINKS(name);
250
+ const hit = sinks.map((re) => re.exec(script)).find(Boolean);
251
+ if (!hit) continue;
252
+ const useIdx = script.slice(0, hit.index).split('\n').length - 1;
253
+ const guarded = validatedBeforeUse(script, name, useIdx);
254
+ const line = lineOf(raw, hit[0].trim().slice(0, 30));
255
+ push({
256
+ id: `pipeline:${fp}:${line}:env-eval:${jobId}:${idx}:${name}`, kind: 'iac',
257
+ severity: guarded ? 'low' : 'critical',
258
+ vuln: 'Pipeline: untrusted github.event input interpolated into shell context (env value later evaluated as code)',
259
+ cwe: 'CWE-78', stride: 'Tampering', file: fp, line, snippet: hit[0].trim(),
260
+ fix: `Env placement is not a sanitizer: \$${name} is parsed as code by this command. Treat it as data only (fixed command, argument array), or validate it against an allow-list first.`,
261
+ workflow: fp, job: jobId, step: stepName, inputOrigin: us[0].origin, inputOriginKind: us[0].kind,
262
+ sensitiveOperation: `second interpretation of $${name} (${shell})`,
263
+ privilegeBoundary: `trigger ${trig.join(', ')}; permissions: ${writes.join(', ') || 'default'}`,
264
+ disposition: guarded ? 'needs-review' : 'detected',
265
+ guards: guarded ? [`validation of $${name} before use`] : [],
266
+ });
267
+ }
268
+ });
269
+ }
270
+ void dispatch;
271
+ return findings;
272
+ }
@@ -0,0 +1,88 @@
1
+ // Dependency finding basis (PRD FR 08). Records WHY a dependency finding exists
2
+ // and how strong the evidence about the dependency itself is, kept apart from
3
+ // reachability. A version match is a version match: unknown reachability is
4
+ // recorded as unknown and is never a reason to drop or call the finding safe.
5
+ //
6
+ // Version bases, weakest to strongest. None of them is deployment evidence:
7
+ // declared-constraint a range or pin in a manifest
8
+ // locked an entry in a lock file
9
+ // resolved-graph a resolved dependency graph (not produced by this scanner today)
10
+ // deployment-evidence an artifact or runtime inventory (never inferred from the repository)
11
+
12
+ const LOCK_FILES = /(?:^|\/)(?:package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|poetry\.lock|Pipfile\.lock|go\.sum|Gemfile\.lock|composer\.lock|gradle\.lockfile|packages\.lock\.json|stack\.yaml\.lock|cabal\.project\.freeze|flake\.lock|mix\.lock|pubspec\.lock)$/;
13
+
14
+ export function versionBasisOf(file) {
15
+ return LOCK_FILES.test(String(file || '')) ? 'locked' : 'declared-constraint';
16
+ }
17
+
18
+ function rangesOf(sc) {
19
+ if (Array.isArray(sc.affectedRanges) && sc.affectedRanges.length) return sc.affectedRanges;
20
+ return null;
21
+ }
22
+
23
+ /** Add `basis` to every dependency vulnerability finding. Pure with respect to severity and visibility. */
24
+ export function annotateScaBasis(supplyChain) {
25
+ for (const sc of supplyChain || []) {
26
+ if (!sc || sc.type !== 'vulnerable_dep') continue;
27
+ const vb = versionBasisOf(sc.file);
28
+ const gaps = [];
29
+ if (!rangesOf(sc)) gaps.push('affected range not recorded for this advisory');
30
+ if (!sc.advisoryModified) gaps.push('advisory timestamp not recorded');
31
+ if (!sc.fixedVersions || !sc.fixedVersions.length) gaps.push('no fixed version known');
32
+ if (vb === 'declared-constraint') gaps.push('version comes from a declared constraint; the installed version may differ');
33
+ gaps.push('no deployment evidence: a dependency listed here is not proof that it is deployed');
34
+ sc.basis = {
35
+ schema: 'sca-basis/1',
36
+ ecosystem: sc.ecosystem || null,
37
+ package: sc.name || null,
38
+ resolvedVersion: sc.version || null,
39
+ versionBasis: vb,
40
+ source: { file: sc.file || null, line: sc.line ?? null, direct: sc.isDirect ?? null, chain: sc.depChain || [] },
41
+ advisory: {
42
+ id: sc.osvId || null,
43
+ aliases: sc.cveAliases || [],
44
+ affectedRanges: rangesOf(sc),
45
+ fixedVersions: sc.fixedVersions || [],
46
+ source: sc.advisorySource || 'osv.dev',
47
+ modified: sc.advisoryModified || null,
48
+ published: sc.advisoryPublished || null,
49
+ },
50
+ matchBasis: 'version-range',
51
+ reachability: sc.functionReachable || (sc.reachable ? 'import-reachable' : 'unknown'),
52
+ reachabilityNote: 'reachability is optional context and never removes a valid version match',
53
+ deploymentEvidence: 'none',
54
+ gaps,
55
+ };
56
+ }
57
+ return supplyChain;
58
+ }
59
+
60
+ /**
61
+ * Merge findings that are the same advisory under different identifiers (an
62
+ * OSV/GHSA id and its CVE alias) for the same package version. The merged
63
+ * finding keeps every identifier and every affected occurrence, so no
64
+ * component or file is lost. Returns the new list; inputs are not mutated.
65
+ */
66
+ export function dedupeAdvisoryAliases(supplyChain) {
67
+ const out = [];
68
+ const groups = new Map(); // component key -> [{ids:Set, primary}]
69
+ for (const sc of supplyChain || []) {
70
+ if (!sc || sc.type !== 'vulnerable_dep') { out.push(sc); continue; }
71
+ const ck = `${sc.ecosystem}\0${sc.name}\0${sc.version}`;
72
+ const ids = new Set([sc.osvId, ...(sc.cveAliases || [])].filter(Boolean));
73
+ const list = groups.get(ck) || groups.set(ck, []).get(ck);
74
+ const hit = list.find((g) => [...ids].some((i) => g.ids.has(i)));
75
+ const occ = { file: sc.file || null, line: sc.line ?? null, direct: sc.isDirect ?? null, chain: sc.depChain || [] };
76
+ if (!hit) {
77
+ const primary = { ...sc, occurrences: [occ], advisoryIds: [...ids].sort() };
78
+ list.push({ ids, primary });
79
+ out.push(primary);
80
+ continue;
81
+ }
82
+ for (const i of ids) hit.ids.add(i);
83
+ hit.primary.advisoryIds = [...hit.ids].sort();
84
+ if (!hit.primary.occurrences.some((o) => o.file === occ.file && o.line === occ.line)) hit.primary.occurrences.push(occ);
85
+ if (sc.osvId && sc.osvId !== hit.primary.osvId) (hit.primary.mergedAdvisories ||= []).push(sc.osvId);
86
+ }
87
+ return out;
88
+ }
@@ -0,0 +1,55 @@
1
+ // CLI flags for the scan scope (PRD FR 01 / FR 09). Pure: argv in, plain
2
+ // object out. Repeatable flags (`--include-path a --include-path b`) are why
3
+ // this is not read from the CLI's generic last-one-wins flag map.
4
+
5
+ const VALUE_FLAGS = new Set(['--include-path', '--exclude-path', '--include-ignored-path', '--ignore-source']);
6
+ const BOOL_FLAGS = new Set(['--explain-scope', '--respect-gitignore', '--no-respect-gitignore']);
7
+
8
+ export function parseScopeFlags(argv) {
9
+ const out = { active: false, explain: false, errors: [], scope: { respectGitignore: true, ignoreSource: 'local', includePaths: [], excludePaths: [], includeIgnoredPaths: [] } };
10
+ for (let i = 0; i < argv.length; i++) {
11
+ let [k, v] = argv[i].startsWith('--') ? argv[i].split(/=(.*)/s, 2) : [argv[i]];
12
+ if (BOOL_FLAGS.has(k)) {
13
+ out.active = true;
14
+ if (k === '--explain-scope') out.explain = true;
15
+ else out.scope.respectGitignore = k === '--respect-gitignore';
16
+ } else if (VALUE_FLAGS.has(k)) {
17
+ out.active = true;
18
+ if (v === undefined) v = argv[++i];
19
+ if (v === undefined || v.startsWith('--')) { out.errors.push(`${k} needs a value`); continue; }
20
+ if (k === '--ignore-source') {
21
+ if (v === 'all') out.scope.ignoreSource = 'local';
22
+ else if (v === 'repository') out.scope.ignoreSource = 'repository';
23
+ else out.errors.push(`--ignore-source must be "all" or "repository", got "${v}"`);
24
+ } else if (k === '--include-path') out.scope.includePaths.push(v);
25
+ else if (k === '--exclude-path') out.scope.excludePaths.push(v);
26
+ else out.scope.includeIgnoredPaths.push(v);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** Human-readable scope preview: included paths, excluded subtrees, reasons, overrides, policy. */
33
+ export function renderScopePreview(inv, { maxLines = 40 } = {}) {
34
+ const L = [];
35
+ const p = inv.policy;
36
+ L.push(`Scope mode: ${p.mode}${p.respectGitignore ? '' : ' (git ignore rules disabled)'}; tracked-file semantics: ${p.trackedFileSemantics ? 'yes' : 'no'}`);
37
+ if (p.ruleDigest) L.push(`Effective ignore-rule digest: ${p.ruleDigest}`);
38
+ for (const d of p.disclosures) L.push(`Disclosure: ${d}`);
39
+ L.push(`Included: ${inv.included.length} file(s)`);
40
+ for (const f of inv.included.slice(0, maxLines)) L.push(` + ${f}`);
41
+ if (inv.included.length > maxLines) L.push(` ... ${inv.included.length - maxLines} more`);
42
+ const byReason = new Map();
43
+ for (const s of inv.skipped) (byReason.get(s.reason) || byReason.set(s.reason, []).get(s.reason)).push(s);
44
+ L.push(`Skipped: ${inv.skipped.length} entr${inv.skipped.length === 1 ? 'y' : 'ies'}`);
45
+ for (const [reason, items] of [...byReason].sort()) {
46
+ L.push(` ${reason}: ${items.length}`);
47
+ for (const s of items.slice(0, 5)) L.push(` - ${s.path}${s.segment ? ` (segment ${s.segment})` : ''}`);
48
+ if (items.length > 5) L.push(` ... ${items.length - 5} more`);
49
+ }
50
+ if (inv.overrides.length) {
51
+ L.push(`Overrides: ${inv.overrides.length}`);
52
+ for (const o of inv.overrides) L.push(` ! ${o.path} overrode ${o.overrode}`);
53
+ }
54
+ return L.join('\n') + '\n';
55
+ }
@@ -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
+ }