@clear-capabilities/agentic-security-scanner 0.153.1 → 0.154.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 +67 -0
- package/bin/agentic-security.js +123 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/2862.index.js +325 -0
- package/dist/3499.index.js +5 -1
- package/dist/3531.index.js +261 -0
- package/dist/3596.index.js +103 -0
- package/dist/4113.index.js +15 -3
- package/dist/4171.index.js +319 -0
- package/dist/5637.index.js +10 -4
- package/dist/6359.index.js +433 -0
- package/dist/7838.index.js +15 -3
- package/dist/8551.index.js +1164 -0
- package/dist/8587.index.js +410 -0
- package/dist/8714.index.js +98 -0
- package/dist/8798.index.js +218 -0
- package/dist/9220.index.js +35 -3
- package/dist/9560.index.js +26 -3
- package/dist/agentic-security.mjs +51 -51
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +18 -3
- package/src/dataflow/CLAUDE.md +9 -0
- package/src/dataflow/catalog-haskell.js +37 -0
- package/src/dataflow/catalog.js +4 -0
- package/src/dataflow/engine.js +33 -3
- package/src/dataflow/privacy-catalog-haskell.js +50 -0
- package/src/dataflow/privacy-catalog.js +5 -1
- package/src/discovery/CLAUDE.md +6 -0
- package/src/discovery/index.js +12 -3
- package/src/discovery/lenses.js +4 -1
- package/src/discovery/partition.js +28 -0
- package/src/egress/redact.js +8 -0
- package/src/engine.js +140 -19
- package/src/ir/index.js +39 -2
- package/src/language/CLAUDE.md +74 -0
- package/src/language/accuracy.js +90 -0
- package/src/language/aibom.js +307 -0
- package/src/language/assurance.js +193 -0
- package/src/language/bom-validate.js +227 -0
- package/src/language/bom.js +236 -0
- package/src/language/bridges.js +392 -0
- package/src/language/compliance-map.js +61 -0
- package/src/language/context.js +199 -0
- package/src/language/contracts.js +339 -0
- package/src/language/discovery.js +359 -0
- package/src/language/engine-pass.js +120 -0
- package/src/language/fix-lifecycle.js +120 -0
- package/src/language/haskell-adapter.js +51 -0
- package/src/language/haskell-disclosure.js +170 -0
- package/src/language/haskell-findings.js +44 -0
- package/src/language/haskell-fix.js +409 -0
- package/src/language/haskell-grammar.js +78 -0
- package/src/language/haskell-guards.js +382 -0
- package/src/language/haskell-ir.js +1377 -0
- package/src/language/haskell-llm.js +128 -0
- package/src/language/haskell-manifests.js +1290 -0
- package/src/language/haskell-models.js +278 -0
- package/src/language/haskell-parser.js +1073 -0
- package/src/language/haskell-resolved-graph.js +414 -0
- package/src/language/haskell-sca.js +387 -0
- package/src/language/haskell-security-rules.js +332 -0
- package/src/language/haskell-supply.js +140 -0
- package/src/language/haskell-sweep.js +85 -0
- package/src/language/haskell-syntax.js +895 -0
- package/src/language/haskell-web.js +749 -0
- package/src/language/nix-adapter.js +42 -0
- package/src/language/nix-agents.js +155 -0
- package/src/language/nix-build-trust.js +790 -0
- package/src/language/nix-closure.js +331 -0
- package/src/language/nix-eval-isolation.js +274 -0
- package/src/language/nix-fix.js +268 -0
- package/src/language/nix-grammar.js +97 -0
- package/src/language/nix-inventory.js +379 -0
- package/src/language/nix-ir.js +642 -0
- package/src/language/nix-parser.js +908 -0
- package/src/language/nix-privacy.js +114 -0
- package/src/language/nix-sca.js +358 -0
- package/src/language/nix-script-taint.js +885 -0
- package/src/language/nix-secrets.js +396 -0
- package/src/language/nixos-hardening.js +564 -0
- package/src/language/nixos-module-resolver.js +595 -0
- package/src/language/nixos-option-catalog.js +96 -0
- package/src/language/pragma.js +150 -0
- package/src/language/resolved-pass.js +146 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +224 -0
- package/src/language/witness.js +185 -0
- package/src/lineage/CLAUDE.md +8 -0
- package/src/lineage/coverage.js +2 -0
- package/src/lineage/cross-repo-link.js +22 -0
- package/src/lineage/graph-builder.js +5 -1
- package/src/lineage/haskell-view.js +319 -0
- package/src/lineage/index.js +8 -1
- package/src/lineage/nix-view.js +198 -0
- package/src/lineage/sink-registry.js +10 -1
- package/src/lineage/source-registry.js +3 -0
- package/src/lineage/transform-catalog.js +12 -0
- package/src/llm-validator/index.js +16 -2
- package/src/lsp/server.js +69 -9
- package/src/mcp/CLAUDE.md +7 -0
- package/src/mcp/tools.js +26 -3
- package/src/pipeline/coverage-ledger.js +0 -0
- package/src/pipeline/scan-health.js +19 -1
- package/src/posture/accuracy-scorecard.js +51 -0
- package/src/posture/aibom.js +61 -3
- package/src/posture/artifact-registry.js +12 -0
- package/src/posture/auditor-walkthrough.js +41 -0
- package/src/posture/clustering.js +2 -2
- package/src/posture/evidence-bundle.js +7 -1
- package/src/posture/execution-proof.js +15 -0
- package/src/posture/falsification.js +7 -0
- package/src/posture/family-resolve.js +7 -0
- package/src/posture/fix-history.js +40 -0
- package/src/posture/fix-verify.js +15 -3
- package/src/posture/provenance/git-evidence.js +24 -0
- package/src/posture/provenance/origin-resolver.js +14 -2
- package/src/posture/provenance/predicate-replay.js +11 -2
- package/src/posture/provenance/sca-origin.js +20 -3
- package/src/posture/sbom-diff.js +7 -6
- package/src/posture/sbom.js +85 -32
- package/src/posture/sca-policy.js +10 -1
- package/src/posture/sca-verdict.js +8 -1
- package/src/posture/secret-history.js +62 -18
- package/src/posture/stable-id.js +21 -2
- package/src/posture/state-dir.js +21 -0
- package/src/posture/threat-model-auto.js +4 -4
- package/src/posture/triage.js +18 -1
- package/src/posture/watch-mode.js +34 -3
- package/src/pr-delta.js +9 -4
- package/src/report/index.js +35 -9
- package/src/runScan.js +56 -5
- package/src/sast/code-injection-multilang.js +15 -0
- package/src/sast/go-extended.js +11 -0
- package/src/sast/pipeline.js +34 -1
package/src/pr-delta.js
CHANGED
|
@@ -26,8 +26,9 @@
|
|
|
26
26
|
import { spawnSync } from 'node:child_process';
|
|
27
27
|
import { hardenGitArgs, hardenGitEnv } from './util/git-hardening.js';
|
|
28
28
|
import { runFullScan } from './engine.js';
|
|
29
|
+
import { isLanguageManifest, isLanguageExcludedPath } from './language/discovery.js';
|
|
29
30
|
|
|
30
|
-
const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md)$/i;
|
|
31
|
+
const FILE_EXT_RE = /\.(?:js|jsx|ts|tsx|mjs|cjs|py|java|cs|kt|go|rb|php|sol|swift|rs|tf|yml|yaml|json|toml|md|hs|lhs|hsc|hs-boot|nix|cabal)$/i;
|
|
31
32
|
const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
|
32
33
|
|
|
33
34
|
// `root` is the PR's repository — a scan target, not this project's own
|
|
@@ -54,23 +55,27 @@ function _listFilesAtRef(root, ref) {
|
|
|
54
55
|
return r.stdout.trim().split('\n').filter(p => {
|
|
55
56
|
if (!p) return false;
|
|
56
57
|
if (p.includes('/node_modules/') || p.includes('/.venv/')) return false;
|
|
57
|
-
|
|
58
|
+
if (isLanguageExcludedPath(p)) return false;
|
|
59
|
+
return FILE_EXT_RE.test(p) || isLanguageManifest(p);
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
|
|
61
63
|
async function _scanAtRef(root, ref) {
|
|
62
64
|
const files = _listFilesAtRef(root, ref);
|
|
63
65
|
const fileContents = {};
|
|
66
|
+
const depFileContents = {};
|
|
64
67
|
for (const f of files) {
|
|
65
68
|
const c = _readFileAtRef(root, ref, f);
|
|
66
|
-
if (c
|
|
69
|
+
if (c == null) continue;
|
|
70
|
+
// Haskell/Nix manifests and lock files are dependency inputs, not code (the same split a working-tree scan makes).
|
|
71
|
+
if (isLanguageManifest(f)) depFileContents[f] = c; else fileContents[f] = c;
|
|
67
72
|
}
|
|
68
73
|
// `provenance:false` — a base-ref snapshot, not the current working state.
|
|
69
74
|
// Beyond the wasted git walks, updateLifecycle marks every open stableId
|
|
70
75
|
// absent from the finding set it is handed as `remediated`; running the PR
|
|
71
76
|
// delta gate would silently rewrite the project's lifecycle store from the
|
|
72
77
|
// base ref's findings.
|
|
73
|
-
return runFullScan({ fileContents, scanRoot: root, provenance: false }, () => {});
|
|
78
|
+
return runFullScan({ fileContents, depFileContents, scanRoot: root, provenance: false }, () => {});
|
|
74
79
|
}
|
|
75
80
|
|
|
76
81
|
function _summary(findings) {
|
package/src/report/index.js
CHANGED
|
@@ -110,6 +110,20 @@ function explainParts(f, { verbose = false } = {}) {
|
|
|
110
110
|
// <confidence>". Nothing here throws on a partially-populated object; every
|
|
111
111
|
// field access is optional-chained or defaulted, because a provenance record
|
|
112
112
|
// that survived a failure path is exactly the input this has to render.
|
|
113
|
+
// Additive Haskell/Nix finding metadata (src/language/contracts.js). Only keys the finding actually carries.
|
|
114
|
+
const _LANGUAGE_KEYS = ['language', 'capability', 'analysisKind', 'evidenceKind', 'originalLocation', 'generatedLocation', 'scope', 'uncertainty', 'resolutionStatus', 'modelVersion', 'trustBoundary', 'route', 'modelNote', 'controls', 'guardNote', 'rule', 'exposure', 'destination', 'source', 'origins', 'sink', 'protection', 'attrPath', 'bridge', 'applicationFlow', 'secretValue', 'environmentVariable', 'provider', 'rotation', 'llmRole', 'llmContext', 'owaspLlm', 'end2endProof', 'chainNote', 'declaredCapability', 'exercised', 'service', 'subject', 'option', 'optionEvidence', 'locationKind', 'entry', 'ignorePragmaIgnored'];
|
|
115
|
+
// Keys that other detectors already use with a different meaning; passed through only for Haskell/Nix findings.
|
|
116
|
+
const _LANGUAGE_ONLY_KEYS = new Set(['rule', 'source', 'sink', 'destination', 'origins', 'protection', 'exposure', 'service']);
|
|
117
|
+
function languageFields(f) {
|
|
118
|
+
const out = {};
|
|
119
|
+
const lang = f.language === 'haskell' || f.language === 'nix';
|
|
120
|
+
for (const k of _LANGUAGE_KEYS) {
|
|
121
|
+
if (_LANGUAGE_ONLY_KEYS.has(k) && !lang) continue;
|
|
122
|
+
if (f[k] !== undefined && f[k] !== null) out[k] = f[k];
|
|
123
|
+
}
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
113
127
|
export function explainProvenance(f) {
|
|
114
128
|
const fp = f && f.findingProvenance;
|
|
115
129
|
if (!fp) return null;
|
|
@@ -274,6 +288,8 @@ export function normalizeFindings(scan){
|
|
|
274
288
|
unvalidated: f.unvalidated === true,
|
|
275
289
|
cross_language: f.cross_language === true,
|
|
276
290
|
family: f.family || null,
|
|
291
|
+
// Haskell/Nix language contract (additive; absent for every other finding, so existing output is unchanged).
|
|
292
|
+
...languageFields(f),
|
|
277
293
|
// Premortem #8: surface the parser field so downstream consumers
|
|
278
294
|
// (UI, SARIF, calibration) see the value the engine backfilled.
|
|
279
295
|
parser: f.parser || null,
|
|
@@ -527,7 +543,8 @@ export function normalizeFindings(scan){
|
|
|
527
543
|
cveAliases: sc.cveAliases || [],
|
|
528
544
|
osvId: sc.osvId || null,
|
|
529
545
|
advisory: sc.advisory || sc.description || '',
|
|
530
|
-
fixedIn: sc.range || null,
|
|
546
|
+
fixedIn: sc.range || (Array.isArray(sc.fixedIn) && sc.fixedIn.length ? sc.fixedIn : null),
|
|
547
|
+
...(Number.isInteger(sc.line) && sc.line > 0 ? { declaringLine: sc.line, declaringFile: sc.file || null } : {}),
|
|
531
548
|
// Feat-9: real-world risk signals
|
|
532
549
|
epssScore: sc.epssScore ?? null,
|
|
533
550
|
epssPercentile: sc.epssPercentile ?? null,
|
|
@@ -1034,15 +1051,24 @@ export function toSARIF(scan, meta={}){
|
|
|
1034
1051
|
const chain = Array.isArray(f.chain) ? f.chain : [];
|
|
1035
1052
|
const codeFlows = chain.length >= 2 ? [{
|
|
1036
1053
|
threadFlows: [{
|
|
1037
|
-
locations: chain.map((hop, idx) =>
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1054
|
+
locations: chain.map((hop, idx) => {
|
|
1055
|
+
// A hop inside a GENERATED script (a shell script a Nix string builds) has coordinates in the script
|
|
1056
|
+
// text, not in the file. It is reported at the nearest ORIGINAL span (the interpolation that feeds it)
|
|
1057
|
+
// and carries its generated position and origin in properties, so no reader mistakes script line 1 for
|
|
1058
|
+
// line 1 of the Nix file.
|
|
1059
|
+
const generated = hop.generated === true;
|
|
1060
|
+
const orig = generated && f.originalLocation && Number.isInteger(f.originalLocation.startLine) ? f.originalLocation : null;
|
|
1061
|
+
const region = orig
|
|
1062
|
+
? { startLine: Math.max(1, orig.startLine), ...(Number.isInteger(orig.startColumn) ? { startColumn: orig.startColumn + 1 } : {}), ...(Number.isInteger(orig.endLine) ? { endLine: orig.endLine } : {}), ...(Number.isInteger(orig.endColumn) ? { endColumn: orig.endColumn + 1 } : {}) }
|
|
1063
|
+
: { startLine: Math.max(1, hop.line || 1), ...(Number.isInteger(hop.column) ? { startColumn: hop.column + 1 } : {}), ...(Number.isInteger(hop.endLine) ? { endLine: hop.endLine } : {}), ...(Number.isInteger(hop.endColumn) ? { endColumn: hop.endColumn + 1 } : {}) };
|
|
1064
|
+
return {
|
|
1065
|
+
location: {
|
|
1066
|
+
physicalLocation: { artifactLocation: { uri: hop.file || f.file }, region },
|
|
1067
|
+
message: { text: `${hop.label || (idx === 0 ? 'source' : idx === chain.length - 1 ? 'sink' : 'propagation')}${generated ? ' [generated script]' : ''}` },
|
|
1042
1068
|
},
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1045
|
-
})
|
|
1069
|
+
...(generated ? { kinds: ['generated'], properties: { generated: true, generatedLocation: { line: hop.line, ...(Number.isInteger(hop.column) ? { startColumn: hop.column } : {}), ...(Number.isInteger(hop.endLine) ? { endLine: hop.endLine } : {}), ...(Number.isInteger(hop.endColumn) ? { endColumn: hop.endColumn } : {}) }, ...(f.attrPath ? { generatedBy: f.attrPath } : {}) } } : {}),
|
|
1070
|
+
};
|
|
1071
|
+
}),
|
|
1046
1072
|
}],
|
|
1047
1073
|
}] : undefined;
|
|
1048
1074
|
const fixes = f.remediation ? [{
|
package/src/runScan.js
CHANGED
|
@@ -9,6 +9,7 @@ import { runFullScan, shouldScan, isKubernetesManifest, isCloudFormationTemplate
|
|
|
9
9
|
import { appendScanSnapshot } from './posture/security-trend.js';
|
|
10
10
|
import { recover as recoverFixHistory } from './posture/fix-history.js';
|
|
11
11
|
import { stampScan } from './posture/ruleset-version.js';
|
|
12
|
+
import { isLanguageManifest, isLanguageExcludedPath, isLanguageSource, readExplicitExports, WALK_IGNORE_GLOBS, impactedFiles, buildImportGraph, importClosure } from './language/discovery.js';
|
|
12
13
|
|
|
13
14
|
const DEP_FILE_NAMES = new Set([
|
|
14
15
|
'package.json','package-lock.json','yarn.lock','pnpm-lock.yaml',
|
|
@@ -40,6 +41,9 @@ export function isDepFile(rel) {
|
|
|
40
41
|
if (DEP_FILE_NAMES.has(base)) return true;
|
|
41
42
|
if (REQUIREMENTS_FILE.test(base)) return true;
|
|
42
43
|
if (REQUIREMENTS_DIR_FILE.test(rel.split(path.sep).join('/'))) return true;
|
|
44
|
+
// Haskell/Nix project manifests (cabal, stack, flake, lockfiles): a change to
|
|
45
|
+
// any of them moves the global checkpoint key via depFileContents.
|
|
46
|
+
if (isLanguageManifest(rel) && !isLanguageExcludedPath(rel)) return true;
|
|
43
47
|
return false;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -68,18 +72,27 @@ const DEFAULT_IGNORE = [
|
|
|
68
72
|
'**/dist/**','**/build/**','**/.next/**','**/venv/**','**/env/**','**/.venv/**',
|
|
69
73
|
'**/target/**','**/bin/**','**/obj/**','**/.cache/**','**/coverage/**',
|
|
70
74
|
'**/bower_components/**','**/tests/**','**/test/**','**/__tests__/**','**/spec/**','**/mocks/**',
|
|
75
|
+
// Haskell/Nix build output and store closures; explicit exports inside them
|
|
76
|
+
// are read by exact path below, never by walking.
|
|
77
|
+
...WALK_IGNORE_GLOBS,
|
|
71
78
|
];
|
|
72
79
|
|
|
73
80
|
export async function readTree(root, { ignore = [] } = {}) {
|
|
74
81
|
const entries = await listFiles(root, { ignore: [...DEFAULT_IGNORE, ...ignore] });
|
|
75
82
|
const fileContents = {};
|
|
76
83
|
const depFileContents = {};
|
|
84
|
+
const oversize = [];
|
|
77
85
|
for (const rel of entries) {
|
|
78
86
|
const abs = path.join(root, rel);
|
|
79
87
|
let stat;
|
|
80
88
|
try { stat = await fs.stat(abs); } catch { continue; }
|
|
81
89
|
const dep = isDepFile(rel);
|
|
82
|
-
if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES))
|
|
90
|
+
if (stat.size > (dep ? MAX_DEP_BYTES : MAX_CODE_BYTES)) {
|
|
91
|
+
// A Haskell/Nix source or manifest over the read cap is not analysed. That is a coverage gap a reader must be told about,
|
|
92
|
+
// not a quiet omission: it is listed (path, size, cap) and reaches scan health as a condition.
|
|
93
|
+
if (isLanguageSource(rel) || isLanguageManifest(rel)) oversize.push({ file: rel, bytes: stat.size, cap: dep ? MAX_DEP_BYTES : MAX_CODE_BYTES });
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
83
96
|
let content;
|
|
84
97
|
try { content = await fs.readFile(abs, 'utf8'); } catch { continue; }
|
|
85
98
|
const base = path.basename(rel);
|
|
@@ -107,7 +120,14 @@ export async function readTree(root, { ignore = [] } = {}) {
|
|
|
107
120
|
// project index parses key=value lines for cross-file lookup.
|
|
108
121
|
else if (/\.properties$/i.test(rel)) fileContents[rel] = content;
|
|
109
122
|
}
|
|
110
|
-
|
|
123
|
+
// Explicit Cabal plan / Stack / Nix exports live inside directories the walk
|
|
124
|
+
// prunes. Read them by exact path next to each project manifest.
|
|
125
|
+
try {
|
|
126
|
+
const manifests = Object.keys(depFileContents).concat(Object.keys(fileContents)).filter(isLanguageManifest);
|
|
127
|
+
const { contents } = readExplicitExports(root, manifests);
|
|
128
|
+
for (const [rel, text] of Object.entries(contents)) depFileContents[rel] = text;
|
|
129
|
+
} catch { /* exports are best-effort evidence */ }
|
|
130
|
+
return { fileContents, depFileContents, oversize };
|
|
111
131
|
}
|
|
112
132
|
|
|
113
133
|
// Feat-10: incremental scan via `--changed-since <git-ref>`. Returns the set of
|
|
@@ -146,6 +166,14 @@ export function changedSince(root, gitRef) {
|
|
|
146
166
|
}
|
|
147
167
|
}
|
|
148
168
|
|
|
169
|
+
const POLICY_ONLY_HEAVY = ['**/node_modules/**', '**/.git/**', '**/venv/**', '**/.venv/**', '**/__pycache__/**', ...WALK_IGNORE_GLOBS];
|
|
170
|
+
/** Haskell/Nix sources the default ignore list kept out of the scan (relative paths, sorted, capped). */
|
|
171
|
+
export async function languageFilesIgnoredByPolicy(root, fileContents, depFileContents) {
|
|
172
|
+
const all = await listFiles(root, { ignore: POLICY_ONLY_HEAVY });
|
|
173
|
+
const seen = new Set([...Object.keys(fileContents), ...Object.keys(depFileContents)]);
|
|
174
|
+
return all.filter((rel) => (isLanguageSource(rel) || isLanguageManifest(rel)) && !isLanguageExcludedPath(rel) && !seen.has(rel)).sort().slice(0, 500);
|
|
175
|
+
}
|
|
176
|
+
|
|
149
177
|
export async function runScan(rootDir, opts = {}) {
|
|
150
178
|
const root = path.resolve(rootDir);
|
|
151
179
|
const startedAt = new Date().toISOString();
|
|
@@ -173,6 +201,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
173
201
|
// path added later must opt OUT explicitly rather than silently inherit a
|
|
174
202
|
// false claim of coverage.
|
|
175
203
|
let completeScan = true;
|
|
204
|
+
let sizeSkipped = [];
|
|
176
205
|
if (opts.fileContents) {
|
|
177
206
|
// Caller-supplied file list (MCP `scan_diff`, the LSP's on-save scan): by
|
|
178
207
|
// construction a subset of the tree, not a scan of it.
|
|
@@ -180,16 +209,38 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
180
209
|
depFileContents = opts.depFileContents || {};
|
|
181
210
|
completeScan = false;
|
|
182
211
|
} else {
|
|
183
|
-
({ fileContents, depFileContents } = await readTree(root, opts));
|
|
212
|
+
({ fileContents, depFileContents, oversize: sizeSkipped } = await readTree(root, opts));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Haskell/Nix sources under a default-ignored directory (tests, specs, mocks, bin, build, dist, vendor) are not
|
|
216
|
+
// scanned, exactly as for every other language. That policy is kept, but it is never silent: the files it kept
|
|
217
|
+
// out are listed and reach scan health as a stated limitation.
|
|
218
|
+
let policyExcluded = [];
|
|
219
|
+
if (!opts.fileContents && Object.keys(fileContents).some(isLanguageSource)) {
|
|
220
|
+
try { policyExcluded = await languageFilesIgnoredByPolicy(root, fileContents, depFileContents); } catch { policyExcluded = []; }
|
|
184
221
|
}
|
|
185
222
|
|
|
186
223
|
// Feat-10: incremental mode — restrict the scan to files changed since a git ref
|
|
187
224
|
if (opts.changedSince) {
|
|
188
225
|
const changed = changedSince(root, opts.changedSince);
|
|
189
226
|
if (changed) {
|
|
227
|
+
const keep = new Set(changed);
|
|
228
|
+
// Haskell and Nix results depend on more than the file that changed: a module that imports a changed module
|
|
229
|
+
// may now be (or stop being) vulnerable, an effective NixOS value is decided by the modules it imports, and a
|
|
230
|
+
// lock file, Cabal flags or an explicit export can change every file's verdict. So the scan set is the changed
|
|
231
|
+
// files, everything that transitively imports one (all language sources for a manifest change), and the import
|
|
232
|
+
// closure of those as context. A deleted language file or manifest widens to every language source, because
|
|
233
|
+
// its importers can no longer be found from the tree on disk.
|
|
234
|
+
const langSources = Object.keys(fileContents).filter(isLanguageSource);
|
|
235
|
+
if (langSources.length) {
|
|
236
|
+
const deletedLang = [...changed].some((c) => (isLanguageSource(c) || isLanguageManifest(c)) && !(c in fileContents) && !(c in depFileContents));
|
|
237
|
+
const impacted = deletedLang ? langSources : impactedFiles(fileContents, depFileContents, [...changed]);
|
|
238
|
+
const { graph } = buildImportGraph(fileContents);
|
|
239
|
+
for (const f of impacted) { keep.add(f); for (const d of importClosure(graph, f).files) keep.add(d); }
|
|
240
|
+
}
|
|
190
241
|
const filtered = {};
|
|
191
242
|
for (const f of Object.keys(fileContents)) {
|
|
192
|
-
if (
|
|
243
|
+
if (keep.has(f)) filtered[f] = fileContents[f];
|
|
193
244
|
}
|
|
194
245
|
fileContents = filtered;
|
|
195
246
|
// Only when the filter actually applied. A `changedSince` that resolved
|
|
@@ -203,7 +254,7 @@ export async function runScan(rootDir, opts = {}) {
|
|
|
203
254
|
|
|
204
255
|
// R8: `resume` is opt-in. Left undefined here, runFullScan falls back to the
|
|
205
256
|
// AGENTIC_SECURITY_RESUME=1 env var, which is off by default.
|
|
206
|
-
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan }, opts.onProgress || (()=>{}));
|
|
257
|
+
const scan = await runFullScan({ fileContents, depFileContents, scanRoot: root, resume: opts.resume, deep: opts.deep, deepInCi: opts.deepInCi, completeScan, policyExcluded, sizeSkipped }, opts.onProgress || (()=>{}));
|
|
207
258
|
// Premortem 2R4.2: stamp ruleset version + source on the scan result, and
|
|
208
259
|
// notify if the operator pinned a different version than what's installed.
|
|
209
260
|
try { stampScan(root, scan); } catch {}
|
|
@@ -123,6 +123,20 @@ function _lang(fp) {
|
|
|
123
123
|
return null;
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
// True when `idx` sits inside a '…' or "…" literal on its own line. A sink name
|
|
127
|
+
// quoted in a string (a rule library's `fix:`/`example:` text, a log message) is
|
|
128
|
+
// prose, not a call. Backticks are deliberately not tracked: a template literal's
|
|
129
|
+
// `${…}` holds real code, and erring toward "this is code" is the safe direction.
|
|
130
|
+
function _insideQuote(code, idx) {
|
|
131
|
+
let q = null;
|
|
132
|
+
for (let i = code.lastIndexOf('\n', idx - 1) + 1; i < idx; i++) {
|
|
133
|
+
const c = code[i];
|
|
134
|
+
if (q) { if (c === '\\') i++; else if (c === q) q = null; }
|
|
135
|
+
else if (c === '\'' || c === '"') q = c;
|
|
136
|
+
}
|
|
137
|
+
return q !== null;
|
|
138
|
+
}
|
|
139
|
+
|
|
126
140
|
export function scanCodeInjectionMultilang(fp, raw) {
|
|
127
141
|
if (!raw || raw.length > 500_000) return [];
|
|
128
142
|
const lang = _lang(fp);
|
|
@@ -137,6 +151,7 @@ export function scanCodeInjectionMultilang(fp, raw) {
|
|
|
137
151
|
const r = new RegExp(re.source, re.flags);
|
|
138
152
|
let m;
|
|
139
153
|
while ((m = r.exec(code))) {
|
|
154
|
+
if (_insideQuote(code, m.index + Math.max(0, m[0].search(/\w/)))) continue;
|
|
140
155
|
const line = lineOf(code, m.index);
|
|
141
156
|
const id = `code-injection-ml:${fp}:${line}:${key}`;
|
|
142
157
|
if (seen.has(id)) continue;
|
package/src/sast/go-extended.js
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// - exec.Command shell-form exec.Command("sh", "-c", <var>) vs argv-form
|
|
6
6
|
// - http.Client custom-transport http.Get(<var>) or http.NewRequest with
|
|
7
7
|
// user-controlled URL and no allowlist
|
|
8
|
+
// - package-level http.Get/Post runs on http.DefaultClient, which has no
|
|
9
|
+
// timeout (DoS: one stalled upstream pins
|
|
10
|
+
// the goroutine and its connection forever)
|
|
8
11
|
//
|
|
9
12
|
// These patterns are narrow and complement the existing regex-based Go rules
|
|
10
13
|
// (GORM raw SQL, source patterns for net/http, Echo, Chi, Gin).
|
|
@@ -38,6 +41,14 @@ const FINDINGS = [
|
|
|
38
41
|
vuln: 'SSRF — http.Get/Post with variable URL',
|
|
39
42
|
remediation: 'Allowlist the destination host before any net/http call. Use `net/url.Parse(target)` then check `parsed.Host` against an explicit allowlist. Reject RFC1918 (10/8, 172.16/12, 192.168/16) and the cloud metadata addresses (169.254.169.254, fd00:ec2::254).',
|
|
40
43
|
},
|
|
44
|
+
{
|
|
45
|
+
id: 'go-http-no-timeout', severity: 'medium', cwe: 'CWE-400', family: 'dos-no-timeout',
|
|
46
|
+
// Only the package-level helpers. `client.Get(...)` is the caller's own
|
|
47
|
+
// client, which may well set Timeout, so it is deliberately not matched.
|
|
48
|
+
re: /\bhttp\.(?:Get|Post|Head|PostForm)\s*\(/g,
|
|
49
|
+
vuln: 'Missing Timeout on Outbound HTTP Request (DoS)',
|
|
50
|
+
remediation: 'http.Get/Post/Head/PostForm use http.DefaultClient, which never times out. Build a client instead: `c := &http.Client{Timeout: 10 * time.Second}` and call `c.Get(...)`, or pass a context with a deadline to http.NewRequestWithContext.',
|
|
51
|
+
},
|
|
41
52
|
{
|
|
42
53
|
id: 'go-newrequest-user-url', severity: 'high', cwe: 'CWE-918', family: 'ssrf',
|
|
43
54
|
re: /\bhttp\.NewRequest(?:WithContext)?\s*\(\s*[^,]+,\s*(?!"[^"]*"\s*[,)])[a-zA-Z_]\w*/g,
|
package/src/sast/pipeline.js
CHANGED
|
@@ -14,6 +14,38 @@ const _GH_WORKFLOW_RE = /(?:^|\/)\.github\/workflows\/.*\.ya?ml$/i;
|
|
|
14
14
|
const _NONPROD_RE = /(?:^|\/)(?:tests?|examples?|fixtures?)\//i;
|
|
15
15
|
|
|
16
16
|
const PIPELINE_PATTERNS = [
|
|
17
|
+
// ── build hooks common to Haskell (ghcup, Cabal, Stack, Hackage) and Nix pipelines ──
|
|
18
|
+
{
|
|
19
|
+
re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|da)?sh\b/g,
|
|
20
|
+
vuln: 'Pipeline: remote script piped into a shell (unverified installer)',
|
|
21
|
+
sev: 'high', cwe: 'CWE-494',
|
|
22
|
+
fix: 'Download the installer to a file, verify its published checksum or signature, then run it. Installers for ghcup, Nix and similar tools are a common supply-chain target when piped straight into `sh`.',
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
re: /\bnix\s+(?:run|shell|develop|build|profile\s+install|eval)\b[^\n]*?\b(?:github|gitlab|sourcehut):[\w.-]+\/[\w.-]+(?!\/[0-9a-f]{40}\b)(?=[\s#?]|$)/g,
|
|
26
|
+
vuln: 'Pipeline: Nix flake reference executed without a pinned revision',
|
|
27
|
+
sev: 'high', cwe: 'CWE-829',
|
|
28
|
+
fix: 'Pin the flake reference to a full commit (`github:owner/repo/<40-hex-rev>`) or run it from this repository\'s own locked flake, so CI cannot execute whatever the branch points to today.',
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
re: /\bnix\b[^\n]*(?:--accept-flake-config|--option\s+sandbox\s+false|--option\s+substituters\s+http:|--extra-trusted-public-keys|--impure\b)/g,
|
|
32
|
+
vuln: 'Pipeline: Nix evaluation or build trust relaxed in CI',
|
|
33
|
+
sev: 'medium', cwe: 'CWE-693',
|
|
34
|
+
fix: 'Do not accept a flake\'s own configuration, disable the sandbox, add unauthenticated substituters or evaluate impurely in CI. Put the trust decision in the repository\'s reviewed configuration instead.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
re: /^env\s*:\s*\n(?:[ \t]+[^\n]*\n)*?[ \t]+[A-Za-z0-9_]+\s*:\s*\$\{\{\s*secrets\.[A-Z0-9_]+\s*\}\}/gm,
|
|
38
|
+
vuln: 'Pipeline: secret exposed at workflow-wide environment scope',
|
|
39
|
+
sev: 'medium', cwe: 'CWE-522',
|
|
40
|
+
fix: 'Set secrets such as a Hackage or Cachix token on the single step that needs them (`env:` under that step), not at the top of the workflow where every step and every third-party action can read them.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
re: /\bref\s*:\s*\$\{\{\s*github\.(?:event\.pull_request\.head\.(?:sha|ref)|head_ref)\s*\}\}/g,
|
|
44
|
+
vuln: 'Pipeline: pull_request_target checks out untrusted pull request code',
|
|
45
|
+
sev: 'high', cwe: 'CWE-829',
|
|
46
|
+
fix: 'A `pull_request_target` workflow runs with repository secrets. Never check out and build the pull request head in it (a Cabal custom Setup.hs, a Nix flake or a build hook would run attacker code with those secrets); use `pull_request`, or split the privileged step.',
|
|
47
|
+
contextRe: /\bpull_request_target\b/,
|
|
48
|
+
},
|
|
17
49
|
{
|
|
18
50
|
re: /\buses\s*:\s*[\w-]+\/[\w-]+@(?:main|master|latest)\b/g,
|
|
19
51
|
vuln: 'Pipeline: GitHub Action pinned to floating tag',
|
|
@@ -96,7 +128,7 @@ export function scanPipeline(fp, raw) {
|
|
|
96
128
|
// `uses:` step with its pin (SHA or tag), every secret reference, every
|
|
97
129
|
// permissions block. The PBOM is meant to be stored alongside the SBOM and
|
|
98
130
|
// produced from the same scan.
|
|
99
|
-
export function toPBOM(fileContents, meta = {}) {
|
|
131
|
+
export function toPBOM(fileContents, meta = {}, extras = {}) {
|
|
100
132
|
const workflows = [];
|
|
101
133
|
for (const [fp, raw] of Object.entries(fileContents || {})) {
|
|
102
134
|
if (!_GH_WORKFLOW_RE.test(fp.replace(/\\/g, '/'))) continue;
|
|
@@ -120,6 +152,7 @@ export function toPBOM(fileContents, meta = {}) {
|
|
|
120
152
|
version: '1',
|
|
121
153
|
generatedAt: meta.startedAt || new Date().toISOString(),
|
|
122
154
|
workflows,
|
|
155
|
+
...(extras.languageBuild && (extras.languageBuild.haskell || extras.languageBuild.nix) ? { languageBuild: extras.languageBuild } : {}),
|
|
123
156
|
summary: {
|
|
124
157
|
totalWorkflows: workflows.length,
|
|
125
158
|
totalActions: workflows.reduce((n, w) => n + w.uses.length, 0),
|