@clear-capabilities/agentic-security-scanner 0.153.2 → 0.155.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 +47 -0
- package/bin/agentic-security.js +131 -19
- package/dist/1444.index.js +64 -19
- package/dist/1905.index.js +46 -3
- package/dist/1919.index.js +445 -0
- 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 +1192 -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 +20 -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 +125 -14
- 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 +174 -0
- package/src/language/secrets.js +323 -0
- package/src/language/state-artifacts.js +78 -0
- package/src/language/support-registry.js +289 -0
- package/src/language/trusted-inputs.js +38 -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/pipeline.js +34 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,53 @@
|
|
|
9
9
|
> make the history less accurate, not more.
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
## 0.155.0 - premortem remediation (trust boundary, unseen-shape measurement), NixOS VM check
|
|
13
|
+
|
|
14
|
+
An adversarial premortem of 0.154.0 found the following; each is fixed here and covered by `test/language/language-trust-boundary.test.js`.
|
|
15
|
+
|
|
16
|
+
- **Trust boundary.** Advisory snapshots are read only from the operator (environment variable or `$XDG_CONFIG_HOME/agentic-security/`). A snapshot inside the scanned project is ignored and the scan says so, so a repository can no longer ship an empty feed to look clean. Nix export freshness is derived by the scanner from `flake.lock`, project-supplied exports and plans are disclosed, a refused export contributes nothing, and a Cabal plan that contradicts the freeze file is flagged.
|
|
17
|
+
- **Unseen-shape split.** The holdout shares its code shapes with development, so it measures robustness to renaming only. A new `unseen` split (300 author-labelled cases, 3 new vulnerable and 3 new safe shapes per family) is measured once and never tuned against. Its result is stored beside every holdout row with 95% Clopper-Pearson intervals and strict precision. It is **below target** for Haskell SAST (R 79.2%), Haskell taint (P 77.8%) and Nix config-sast (P 86.1%); those are engine gaps, not fixed here. The `auth` row is now promoted from its own holdout evidence.
|
|
18
|
+
- **Completion honesty.** A release check (`completion-declared`) fails when `docs/completion-status.json` lists unverified requirements and the changelog has no paragraph naming them. Chrome-dependent export tests are a separate informational job rather than a silent skip.
|
|
19
|
+
- **Nix package fix (found by the first hosted NixOS guest run).** The packaged `agentic-security-mcp` and `agentic-security-lsp` wrappers ran `bin/*.js`, which import `src/` and therefore npm dependencies the Nix package does not ship, so both would have crashed for a Nix user. They now run from the self-contained bundle, and the CLI gained an `lsp` subcommand (the same server the editor plugins start). The flake's package source is now exactly the files it installs, so an offline rebuild elsewhere reproduces the same derivation.
|
|
20
|
+
- **Destructive-command guard.** `hooks/pre-bash-guard.js` judged `rm` by text, so it refused any absolute path (and `docker run --rm -v /abs/...`) while missing nothing it should. Deletion (`rm`, `rmdir`, `unlink`, `find -delete`) is now path-aware: targets are parsed and resolved (`~`, `$HOME`, relative paths, `cd`, symlinks) and allowed only strictly inside an allowed root (default `~/code`, set with `allowedRoots` in `.agentic-security/destructive-guard.json`). Anything outside, anything unresolvable (`$VAR`, command substitution, `xargs`) and the root itself is refused. It cannot see deletion done inside another program, so it guards against mistakes and is not a sandbox. Pinned by `test/pre-bash-guard.test.js`.
|
|
21
|
+
- **Not done.** Peak scan memory is measured under a stated heap cap and was **not reduced**: a lazy-token experiment retained less but changed no measurement, so it was reverted. `flake.nix` is now locked (`flake.lock`), built and checked on aarch64-linux in a `nixos/nix` container (Nix 2.35.2) and on x86_64-linux on a hosted runner; aarch64-darwin is declared but unbuilt, and x86_64-darwin is dropped (current nixpkgs no longer supports it).
|
|
22
|
+
|
|
23
|
+
Partial release: the loop controller lists NIX-011, NIX-012 and REL-001 as not verified. NIX-011 and NIX-012 passed on hosted CI in this release's pull request (the NIX-011 suite on Nix 2.24.10; the NIX-012 suite inside a real NixOS guest booted by the flake's `nixos-host` check on an x86_64 runner with KVM), but that is CI evidence, not controller-signed evidence, because the controller runs where `nix` and `/etc/NIXOS` are absent. The aarch64 NixOS guest was not run: the arm runner has no KVM and a software-emulated guest does not boot in any usable time. REL-001 is the controller's own final acceptance and needs those two to be controller-verified first.
|
|
24
|
+
|
|
25
|
+
## 0.154.0 - Haskell and Nix/NixOS support, with every limit stated
|
|
26
|
+
|
|
27
|
+
Haskell and Nix/NixOS are now scanned by the same commands as every other language, with no compiler, no `nix` binary and no
|
|
28
|
+
network. This entry records what is **measured** and what is **not**; nothing here announces completion beyond the evidence.
|
|
29
|
+
|
|
30
|
+
- **Analysis.** Dependency-free Haskell and Nix parsers over SHA-256-pinned grammar tables; Haskell taint (SQL, command, path,
|
|
31
|
+
SSRF, HTML), weak crypto, logging, resource and web-route authentication rules; the NixOS effective-configuration resolver with
|
|
32
|
+
hardening, build-trust and store-secret rules; Haskell and Nix personal-data flows in the Data Flow Explorer; a Haskell
|
|
33
|
+
catalog generated from one model registry. Unresolved constructs, conditional options, stale inputs and missing feeds become
|
|
34
|
+
scan-health conditions, so a scan that could not check something is `partial`, never complete.
|
|
35
|
+
- **Supply chain and BOM.** Hackage components with PVP-aware advisory matching (a snapshot you provide), a resolved graph from a
|
|
36
|
+
fresh Cabal plan or Stack export, Nix flake inputs, an exported closure matched by upstream identity with patch evidence, and
|
|
37
|
+
AI-BOM entries for model calls and inference services. License data is absent for these records and is disclosed, not guessed.
|
|
38
|
+
- **Fixes.** Verified, tiered, reversible fixes for five Haskell shapes and for Nix effective settings, plus dependency-bound
|
|
39
|
+
and flake-input edits labelled as needing a re-resolve or re-lock. The planner now follows a flow into the callee that holds
|
|
40
|
+
the dangerous call. A fix a gate refuses is reported, not applied.
|
|
41
|
+
- **CLI reach.** `fix` and `undo` now work for these languages, `hunt` includes `.hs`/`.nix`, `--since-baseline` filters every
|
|
42
|
+
bucket (a baselined dependency finding no longer reappears as new), the edit hook catches credentials split across literals, and
|
|
43
|
+
a Cabal or Nix project is recognised as a project root for state.
|
|
44
|
+
- **Support, measured.** `docs/language-support.md` records per-capability status from a frozen holdout. On the synthetic,
|
|
45
|
+
template-generated QA-001 corpus every measured layer scores P/R/F1 100%, which says the engine is robust over those shapes and
|
|
46
|
+
**not** that it is accurate on arbitrary real code. `auth` (Haskell) and `nix-eval` / `nixos-host` (Nix) are **blocked**: no
|
|
47
|
+
`ghc`, `nix` or NixOS host was available where this was built.
|
|
48
|
+
- **Tooling.** The `npm test` gate now runs the Haskell, Nix and language scopes; `language-stress` and `language-tools` have
|
|
49
|
+
their own scripts. New CI jobs (`language-suites`, `language-tools-ghc`, `nixos-runtime`) carry explicit deadlines; a
|
|
50
|
+
`language-support-gate` joins pre-push and the release check. A bounded background-loop controller and runbook
|
|
51
|
+
(`docs/guides/loop-engineering.md`) are a development tool, not part of the package.
|
|
52
|
+
- **Docs.** New Haskell, Nix/NixOS, NixOS-install and loop guides; tables and example outputs are generated from the registries and
|
|
53
|
+
from the built bundle, and stale ones fail `npm run docs:check-language`.
|
|
54
|
+
- **Verified and not verified.** The Haskell route fixtures compile under GHC 9.4.8 and the Haskell support table is 9 of 9. NIX-011 (a
|
|
55
|
+
real controlled evaluation) and NIX-012 (the scanner on a NixOS host, VM checks, and the new `flake.nix`) need Nix and NixOS, which
|
|
56
|
+
were never available where this was built: they stay blocked rather than faked, and the flake has never been built. The CI jobs
|
|
57
|
+
that would exercise them are written but have not run.
|
|
58
|
+
|
|
12
59
|
## 0.153.2 - Scanner F1 benchmark and dependency-currency CI repaired
|
|
13
60
|
|
|
14
61
|
The daily `Scanner F1 benchmark` workflow had failed every run since about 2026-08-10. Root causes, each
|
package/bin/agentic-security.js
CHANGED
|
@@ -50,9 +50,11 @@ import { toOSCAL } from '../src/report/oscal.js';
|
|
|
50
50
|
// format had to be added to all three or it silently got human chatter
|
|
51
51
|
// interleaved into its stdout/stderr. One list, one place to update.
|
|
52
52
|
const MACHINE_FORMATS = new Set([
|
|
53
|
-
'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom',
|
|
53
|
+
'json', 'sarif', 'oscal', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom', 'mlbom',
|
|
54
54
|
]);
|
|
55
55
|
function isMachineFormat(fmt) { return MACHINE_FORMATS.has(String(fmt)); }
|
|
56
|
+
// Every output label `scan` produces (the body of cmdScan has one branch per entry).
|
|
57
|
+
const SCAN_FORMATS = new Set(['cli', 'json', 'md', 'markdown', 'sarif', 'oscal', 'stix', 'junit', 'csv', 'html', 'cyclonedx', 'sbom', 'spdx', 'vex', 'openvex', 'pbom', 'aibom', 'aibom-md', 'mlbom', 'ship', 'pro', 'summary']);
|
|
56
58
|
|
|
57
59
|
// Shared stderr progress reporter for any command that runs a fresh scan —
|
|
58
60
|
// the same `\r[phase] current/total file` status line the default `scan`
|
|
@@ -75,7 +77,7 @@ function clearScanProgressLine() {
|
|
|
75
77
|
}
|
|
76
78
|
import { toCycloneDX, toSPDX } from '../src/posture/sbom.js';
|
|
77
79
|
import { toPBOM } from '../src/sast/pipeline.js';
|
|
78
|
-
import { buildAIBOM, aibomToMarkdown } from '../src/posture/aibom.js';
|
|
80
|
+
import { buildAIBOM, aibomToMarkdown, toCycloneDXMLBOM } from '../src/posture/aibom.js';
|
|
79
81
|
import { recordScan, formatStreakLine, formatGradeDelta } from '../src/posture/streak.js';
|
|
80
82
|
import { loadProfile, saveProfile, detectProfile, renderAttributionLine, ATTRIBUTION, ATTRIBUTION_URL } from '../src/posture/profile.js';
|
|
81
83
|
import { applySuppressions, addSoftAcceptance, expiredSoftAcceptances } from '../src/posture/suppressions.js';
|
|
@@ -93,6 +95,16 @@ import * as triage from '../src/posture/triage.js';
|
|
|
93
95
|
import { buildSlackDigest, buildDiscordDigest, postWebhook, buildJiraIssue, buildPrComment, buildSiemEvent, loadIntegrationConfig } from '../src/integrations/index.js';
|
|
94
96
|
|
|
95
97
|
import { stateDir, statePath, withStateWritesDisabled } from '../src/posture/state-dir.js';
|
|
98
|
+
|
|
99
|
+
// A state file is never written THROUGH a symlink: a hostile tree could place a link at `.agentic-security/last-scan.json` and have a
|
|
100
|
+
// scan overwrite whatever it points to. O_NOFOLLOW makes the open itself refuse a link (no check-then-write race); where the flag does
|
|
101
|
+
// not exist an lstat check stands in for it.
|
|
102
|
+
const _NOFOLLOW_FLAGS = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | (fs.constants.O_NOFOLLOW || 0);
|
|
103
|
+
async function _writeStateNoFollow(file, body) {
|
|
104
|
+
if (!fs.constants.O_NOFOLLOW) { try { if ((await fsp.lstat(file)).isSymbolicLink()) return false; } catch { /* absent */ } }
|
|
105
|
+
try { await fsp.writeFile(file, body, { flag: _NOFOLLOW_FLAGS, mode: 0o644 }); return true; }
|
|
106
|
+
catch (e) { if (e && (e.code === 'ELOOP' || e.code === 'EMLINK')) return false; throw e; }
|
|
107
|
+
}
|
|
96
108
|
import { listGeneratedArtifacts } from '../src/posture/artifact-registry.js';
|
|
97
109
|
// last-scan.json integrity helpers — implementation in posture/integrity.js
|
|
98
110
|
// so the MCP server tools can share verification.
|
|
@@ -125,6 +137,7 @@ Commands:
|
|
|
125
137
|
tickets sync --provider <p> Two-way sync findings ↔ GitHub Issues / Linear / Jira
|
|
126
138
|
digest --slack <webhook> Vibecoder: send daily digest to Slack
|
|
127
139
|
mcp Start the MCP stdio server (scan_diff, query_taint, explain_finding, apply_fix)
|
|
140
|
+
lsp Start the Language Server Protocol server on stdio (what the editor plugins run)
|
|
128
141
|
validator-cache stats|gc Inspect / prune .agentic-security/llm-cache/ (use --older-than <days> --dry-run)
|
|
129
142
|
verify [--finding <id>] Re-run the verifier loop on last-scan findings (use --live --target <url> to execute PoCs)
|
|
130
143
|
ask "<question>" [target] Bounded local-model Q&A with read-only tool access (requires AGENTIC_SECURITY_LLM_PRESET=ollama)
|
|
@@ -194,6 +207,8 @@ Commands:
|
|
|
194
207
|
--remote-node <node-id> [--repository <label>]
|
|
195
208
|
[--relationship data_flow] [--rationale <text>]
|
|
196
209
|
[--output <file>] [--yes] [--base-digest <hex>]
|
|
210
|
+
[--local-target <t>] [--local-config <c>]
|
|
211
|
+
[--remote-target <t>] [--remote-config <c>]
|
|
197
212
|
Declare a CrossRepoLink between a node in the
|
|
198
213
|
current locally-scanned graph and a node in a
|
|
199
214
|
remote graph export (dataflow export --format
|
|
@@ -234,7 +249,7 @@ Commands:
|
|
|
234
249
|
Options:
|
|
235
250
|
--profile vibecoder|pro Override profile for this run
|
|
236
251
|
--only sast|sca|secrets Limit scan to one pillar
|
|
237
|
-
--format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md
|
|
252
|
+
--format <fmt> cli | json | md | sarif | oscal | stix | junit | csv | html | cyclonedx | spdx | pbom | aibom | aibom-md | mlbom
|
|
238
253
|
--pack <name> Focus on a curated rule pack (repeatable): owasp-top-10 | cwe-top-25 | llm-security | supply-chain
|
|
239
254
|
--baseline <ref> Diff against a git ref; only findings new vs. that ref count (ci subcommand)
|
|
240
255
|
--fail-on critical|high|medium|low|none ci-mode exit policy (default: critical)
|
|
@@ -470,12 +485,12 @@ async function writeMachineOutput(targetAbs, scan, meta, profile, args) {
|
|
|
470
485
|
}
|
|
471
486
|
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
472
487
|
// Always JSON (used by /security-fix and /security-report).
|
|
473
|
-
await
|
|
488
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
|
|
474
489
|
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
475
490
|
if (profile.profile === 'pro' || profile.machineOutput || (args && args.flags['machine-output'])) {
|
|
476
|
-
await
|
|
491
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
|
|
477
492
|
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
478
|
-
await
|
|
493
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.csv'), toCSV(scan));
|
|
479
494
|
}
|
|
480
495
|
}
|
|
481
496
|
|
|
@@ -613,6 +628,12 @@ async function cmdScan(args) {
|
|
|
613
628
|
// Load persona profile (R1). Persona-aware defaults flow from here.
|
|
614
629
|
const profile = loadPersonaProfile(targetAbs, args);
|
|
615
630
|
const format = args.flags.format || (profile.profile === 'pro' ? 'cli' : 'ship');
|
|
631
|
+
// An output label this command does not produce is an error, not a quiet fallback to the summary: a CI job that
|
|
632
|
+
// asked for `sarif ` or `junit-xml` and got human text would upload garbage and report success.
|
|
633
|
+
if (!SCAN_FORMATS.has(String(format))) {
|
|
634
|
+
process.stderr.write(`agentic-security scan: unsupported --format ${JSON.stringify(String(format))}. Supported: ${[...SCAN_FORMATS].join(' | ')}.\n`);
|
|
635
|
+
return 2;
|
|
636
|
+
}
|
|
616
637
|
const verbose = !!args.flags.verbose;
|
|
617
638
|
const output = args.flags.output;
|
|
618
639
|
const noNet = !!args.flags['no-network'];
|
|
@@ -782,6 +803,9 @@ async function cmdScan(args) {
|
|
|
782
803
|
if (args.flags['set-baseline']) {
|
|
783
804
|
const { normalizeFindings } = await import('../src/report/index.js');
|
|
784
805
|
const baselineIds = new Set(normalizeFindings(scan).map(f => f.stableId || f.id));
|
|
806
|
+
// A supply-chain, secret or logic finding's normalised id includes its line, so an unrelated edit above it would make it
|
|
807
|
+
// look new. Its own producer id (when it has one) does not, so both are recorded.
|
|
808
|
+
for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) for (const f of scan[bucket] || []) if (f && typeof f.id === 'string' && f.id.includes(':')) baselineIds.add(f.id);
|
|
785
809
|
fs.mkdirSync(path.dirname(baselinePath), { recursive: true });
|
|
786
810
|
fs.writeFileSync(baselinePath, JSON.stringify({ ids: [...baselineIds], createdAt: new Date().toISOString(), count: baselineIds.size }, null, 2));
|
|
787
811
|
process.stderr.write(`[baseline] saved ${baselineIds.size} findings as baseline\n`);
|
|
@@ -793,6 +817,20 @@ async function cmdScan(args) {
|
|
|
793
817
|
const baselineSet = new Set(baseline.ids || []);
|
|
794
818
|
const before = scan.findings.length;
|
|
795
819
|
scan.findings = (scan.findings || []).filter(f => !baselineSet.has(f.stableId || f.id));
|
|
820
|
+
// The baseline is built from every bucket (findings, secrets, supply chain, logic), so it must be applied to every bucket:
|
|
821
|
+
// filtering only `findings` left a baselined dependency or secret finding reappearing as "new" on every run.
|
|
822
|
+
// Identity is the one the baseline was written with: the NORMALISED id (raw supply-chain entries carry a different one).
|
|
823
|
+
const { normalizeFindings: _norm } = await import('../src/report/index.js');
|
|
824
|
+
const _empty = { findings: [], secrets: [], supplyChain: [], logicVulns: [], suppressions: [], components: [], routes: [], filesScanned: 0 };
|
|
825
|
+
for (const bucket of ['secrets', 'supplyChain', 'logicVulns']) {
|
|
826
|
+
if (!Array.isArray(scan[bucket])) continue;
|
|
827
|
+
scan[bucket] = scan[bucket].filter((f) => {
|
|
828
|
+
if (!f) return true;
|
|
829
|
+
let id = f.stableId || f.id;
|
|
830
|
+
try { const n = _norm({ ..._empty, [bucket]: [f] })[0]; if (n) id = n.stableId || n.id; } catch { /* keep the raw identity */ }
|
|
831
|
+
return !baselineSet.has(id) && !(typeof f.id === 'string' && f.id.includes(':') && baselineSet.has(f.id));
|
|
832
|
+
});
|
|
833
|
+
}
|
|
796
834
|
process.stderr.write(`[baseline] filtered ${before - scan.findings.length} baseline findings, ${scan.findings.length} new\n`);
|
|
797
835
|
} catch { /* baseline file unreadable, skip */ }
|
|
798
836
|
}
|
|
@@ -997,9 +1035,10 @@ async function cmdScan(args) {
|
|
|
997
1035
|
else if (format === 'cyclonedx' || format === 'sbom') body = JSON.stringify(toCycloneDX(scan, meta), null, 2);
|
|
998
1036
|
else if (format === 'spdx') body = JSON.stringify(toSPDX(scan, meta), null, 2);
|
|
999
1037
|
else if (format === 'vex' || format === 'openvex') body = JSON.stringify(toVex(scan, meta), null, 2);
|
|
1000
|
-
else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta), null, 2);
|
|
1001
|
-
else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, meta), null, 2);
|
|
1002
|
-
else if (format === '
|
|
1038
|
+
else if (format === 'pbom') body = JSON.stringify(toPBOM(scan.fc || {}, meta, { languageBuild: (await import('../src/language/bom.js')).languagePbom({ ...(scan.fc || {}), ...(scan.depFileContents || {}) }) }), null, 2);
|
|
1039
|
+
else if (format === 'aibom') body = JSON.stringify(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), null, 2);
|
|
1040
|
+
else if (format === 'mlbom') body = JSON.stringify(toCycloneDXMLBOM(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }), meta), null, 2);
|
|
1041
|
+
else if (format === 'aibom-md') body = aibomToMarkdown(buildAIBOM(scan, scan.fc || {}, { ...meta, manifests: scan.depFileContents || {} }));
|
|
1003
1042
|
else if (format === 'ship') body = toShipVerdict(scan, { profile: effProfile });
|
|
1004
1043
|
else if (format === 'pro') body = toProTable(scan, { profile: effProfile, columns: args.flags.columns });
|
|
1005
1044
|
else if (format === 'cli') body = toCLIByProfile(scan, { profile: effProfile, columns: args.flags.columns, verbose });
|
|
@@ -1143,10 +1182,16 @@ async function cmdScan(args) {
|
|
|
1143
1182
|
} catch { /* best-effort, offline-degrading — never block a scan */ }
|
|
1144
1183
|
}
|
|
1145
1184
|
const lastScanBody = JSON.stringify(persistedScan, null, 2);
|
|
1146
|
-
await
|
|
1185
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json'), lastScanBody);
|
|
1147
1186
|
try {
|
|
1148
|
-
await
|
|
1187
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'last-scan.json.sig'), _signLastScan(lastScanBody));
|
|
1149
1188
|
} catch { /* non-fatal — sig file is best-effort */ }
|
|
1189
|
+
// X-016: Haskell/Nix analysis metadata and BOM as registered, signed artifacts. Written only when the scan has
|
|
1190
|
+
// Haskell or Nix content, so every other project's state directory is unchanged.
|
|
1191
|
+
try {
|
|
1192
|
+
const { persistLanguageArtifacts } = await import('../src/language/state-artifacts.js');
|
|
1193
|
+
persistLanguageArtifacts(path.resolve(target), { ...scan, scanHealth: persistedScan.scanHealth || scan.scanHealth });
|
|
1194
|
+
} catch { /* non-fatal: metadata artifacts never block a scan */ }
|
|
1150
1195
|
// Sub-project E, increment 5: persist the lineage graph as its own
|
|
1151
1196
|
// artifact, mirroring last-scan.json's own write+sign pattern exactly
|
|
1152
1197
|
// — signLastScan/verifyLastScan are fully generic (an arbitrary string
|
|
@@ -1158,9 +1203,9 @@ async function cmdScan(args) {
|
|
|
1158
1203
|
if (scan.lineageGraph) {
|
|
1159
1204
|
try {
|
|
1160
1205
|
const lineageBody = JSON.stringify(scan.lineageGraph, null, 2);
|
|
1161
|
-
await
|
|
1206
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json'), lineageBody);
|
|
1162
1207
|
try {
|
|
1163
|
-
await
|
|
1208
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'lineage-graph.json.sig'), _signLastScan(lineageBody));
|
|
1164
1209
|
} catch { /* non-fatal — sig file is best-effort, same precedent as last-scan.json.sig above */ }
|
|
1165
1210
|
} catch { /* non-fatal — the lineage artifact write is best-effort and must never block a scan */ }
|
|
1166
1211
|
// M4 deliverable #8 (FR-503 §14, DFG-022, sub-project 8a): ADDITIVELY
|
|
@@ -1301,11 +1346,11 @@ async function cmdCi(args) {
|
|
|
1301
1346
|
if (process.env.AGENTIC_SECURITY_DEBUG === '1') process.stderr.write(`[agentic-security] refusing to write CI artifacts at ${stateDirPath} — no project marker\n`);
|
|
1302
1347
|
} else {
|
|
1303
1348
|
await fsp.mkdir(stateDirPath, { recursive: true });
|
|
1304
|
-
await
|
|
1349
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.json'),
|
|
1305
1350
|
JSON.stringify(toJSON(scan, meta), null, 2));
|
|
1306
|
-
await
|
|
1351
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.sarif'),
|
|
1307
1352
|
JSON.stringify(toSARIF(scan, meta), null, 2));
|
|
1308
|
-
await
|
|
1353
|
+
await _writeStateNoFollow(path.join(stateDirPath, 'findings.junit.xml'),
|
|
1309
1354
|
toJUnit(scan, meta));
|
|
1310
1355
|
}
|
|
1311
1356
|
|
|
@@ -1534,7 +1579,7 @@ async function cmdTriage(args) {
|
|
|
1534
1579
|
const lastScanPath = statePath(target, 'findings.json');
|
|
1535
1580
|
if (fs.existsSync(lastScanPath)) {
|
|
1536
1581
|
const last = JSON.parse(await fsp.readFile(lastScanPath, 'utf8'));
|
|
1537
|
-
triage.syncWithScan(target, last.findings || []);
|
|
1582
|
+
triage.syncWithScan(target, last.findings || [], { scanHealth: last.scanHealth || null });
|
|
1538
1583
|
}
|
|
1539
1584
|
if (sub === 'list') {
|
|
1540
1585
|
const filter = {};
|
|
@@ -2587,8 +2632,8 @@ async function cmdCalibrationReport(args) {
|
|
|
2587
2632
|
// Languages the Layer-1 IR parses. Anything else cannot be partitioned into a
|
|
2588
2633
|
// call-graph focus area, so feeding it to a hunter would spend tokens on files
|
|
2589
2634
|
// the confirmation gate can never corroborate.
|
|
2590
|
-
const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb)$/i;
|
|
2591
|
-
const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**'];
|
|
2635
|
+
const HUNT_EXTS = /\.(?:js|jsx|mjs|cjs|ts|tsx|py|java|cs|kt|go|php|rb|l?hs|nix)$/i;
|
|
2636
|
+
const HUNT_IGNORE = ['node_modules/**', '.git/**', 'dist/**', 'build/**', 'vendor/**', '**/.agentic-security/**', 'dist-newstyle/**', '.stack-work/**', '.direnv/**'];
|
|
2592
2637
|
const HUNT_MAX_FILES = 2000;
|
|
2593
2638
|
|
|
2594
2639
|
async function cmdHunt(args) {
|
|
@@ -3408,6 +3453,41 @@ async function cmdFix(args) {
|
|
|
3408
3453
|
return 0;
|
|
3409
3454
|
}
|
|
3410
3455
|
|
|
3456
|
+
// Haskell and Nix findings have their own verified fixers (tiered, rescanned, reversible by `undo`); the generic path below
|
|
3457
|
+
// knows nothing about them and used to answer "no mechanical fix" for every one.
|
|
3458
|
+
{
|
|
3459
|
+
const { languageOfFinding, languageFixPreview, loadLanguageProject, isLanguageDependencyFinding, languageUpgradePreview } = await import('../src/language/context.js');
|
|
3460
|
+
if (isLanguageDependencyFinding(f)) {
|
|
3461
|
+
if (isApply && sigVerified !== true) {
|
|
3462
|
+
console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
|
|
3463
|
+
return 4;
|
|
3464
|
+
}
|
|
3465
|
+
const proj = loadLanguageProject(scanRoot);
|
|
3466
|
+
const res = await languageUpgradePreview(f, { ...proj.files, ...proj.manifests }, { apply: isApply, root: scanRoot, toRef: args.flags.to || null });
|
|
3467
|
+
if (!res.ok) { console.error(`No dependency fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
|
|
3468
|
+
console.log(`${res.label} (${res.tier}): ${res.explanation}`);
|
|
3469
|
+
console.log(res.diff);
|
|
3470
|
+
for (const c of res.consequences || []) console.log(` note: ${c}`);
|
|
3471
|
+
console.log(isApply ? '\nApplied. This is a source edit only: re-resolve or re-lock, then rescan. Revert with `agentic-security undo`.' : '\nRun with --apply to write this change. Use `agentic-security undo` to revert.');
|
|
3472
|
+
return 0;
|
|
3473
|
+
}
|
|
3474
|
+
if (languageOfFinding(f)) {
|
|
3475
|
+
if (isApply && sigVerified !== true) {
|
|
3476
|
+
console.error(`Refusing to apply: last-scan.json integrity check ${sigVerified === false ? 'failed (tampered)' : 'could not verify (unsigned)'} — re-run \`agentic-security scan\` to refresh.`);
|
|
3477
|
+
return 4;
|
|
3478
|
+
}
|
|
3479
|
+
const proj = loadLanguageProject(scanRoot);
|
|
3480
|
+
const res = await languageFixPreview(f, proj.files, { apply: isApply, root: scanRoot });
|
|
3481
|
+
if (!res.ok) { console.error(`No verified fix for this finding (${res.status}${res.tier ? `, ${res.tier}` : ''}): ${res.reason || 'unsupported'}`); return 4; }
|
|
3482
|
+
console.log(`${res.label || 'FIX'}${res.tier ? ` (${res.tier})` : ''}: ${res.explanation || ''}`.trim());
|
|
3483
|
+
console.log(res.diff);
|
|
3484
|
+
for (const c of res.consequences || []) console.log(` note: ${c}`);
|
|
3485
|
+
if (isApply) { console.log(`\nApplied. Verified: ${Object.entries(res.gates || {}).map(([k, v]) => `${k}=${v && v.ok === false ? 'FAILED' : (v && v.ran === false ? 'not-run' : 'ok')}`).join(' ')}. Revert with \`agentic-security undo\`.`); }
|
|
3486
|
+
else console.log('\nVerified (syntax, rescan: the finding is gone and nothing new appears). Run with --apply to write this change. Use `agentic-security undo` to revert.');
|
|
3487
|
+
return 0;
|
|
3488
|
+
}
|
|
3489
|
+
}
|
|
3490
|
+
|
|
3411
3491
|
// FR-303 (assurance-hardening PRD): confine BEFORE the first read, not just
|
|
3412
3492
|
// before the write — a traversal or symlink-planted f.file was previously
|
|
3413
3493
|
// followed unquestioned for both the preview diff and the apply write (no
|
|
@@ -5558,6 +5638,21 @@ async function cmdGovernancePropose(args) {
|
|
|
5558
5638
|
// through to main()'s own outer catch/process.exit(4), the identical,
|
|
5559
5639
|
// deliberate non-pattern cmdGovernancePropose itself relies on (no local
|
|
5560
5640
|
// try/catch here either).
|
|
5641
|
+
function _federateEndpointScope(node, target, config, graph) {
|
|
5642
|
+
// Node-level limitations when present, plus those of every flow that starts or ends at the node: a Nix or Haskell
|
|
5643
|
+
// flow states its own limits (static configuration evidence, generated-script locations) and a link must not lose them.
|
|
5644
|
+
const lim = new Set(Array.isArray(node && node.limitations) ? node.limitations.filter((x) => typeof x === 'string') : []);
|
|
5645
|
+
for (const f of (graph && Array.isArray(graph.flows) ? graph.flows : [])) {
|
|
5646
|
+
if (f && (f.source === (node && node.id) || f.sink === (node && node.id)) && Array.isArray(f.limitations)) for (const x of f.limitations) if (typeof x === 'string') lim.add(x);
|
|
5647
|
+
}
|
|
5648
|
+
return {
|
|
5649
|
+
target: typeof target === 'string' ? target : null,
|
|
5650
|
+
config: typeof config === 'string' ? config : null,
|
|
5651
|
+
analysis: (node && node.analysis && typeof node.analysis === 'object') ? node.analysis : null,
|
|
5652
|
+
limitations: [...lim].sort(),
|
|
5653
|
+
};
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5561
5656
|
async function cmdFederateDeclare(args) {
|
|
5562
5657
|
const target = args._[2] || '.'; // args._ = ['federate', 'declare', <path>?]
|
|
5563
5658
|
const targetAbs = path.resolve(target);
|
|
@@ -5680,6 +5775,14 @@ async function cmdFederateDeclare(args) {
|
|
|
5680
5775
|
sourceFile: path.resolve(remoteGraphFlag),
|
|
5681
5776
|
graphId: remote.graph.graphId, graphDigest: remote.digest, nodeId: remoteNodeFlag,
|
|
5682
5777
|
},
|
|
5778
|
+
// Scope and limitations of both endpoints, copied from the nodes themselves (a Haskell/Nix configuration node says it
|
|
5779
|
+
// is static configuration evidence), plus the operator's own target/config labels. A declared link is an assertion by
|
|
5780
|
+
// an operator, never an observed data flow, and the record says so.
|
|
5781
|
+
scope: {
|
|
5782
|
+
local: _federateEndpointScope(localNode, args.flags['local-target'], args.flags['local-config'], local.graph),
|
|
5783
|
+
remote: _federateEndpointScope(remoteNode, args.flags['remote-target'], args.flags['remote-config'], remote.graph),
|
|
5784
|
+
},
|
|
5785
|
+
evidence: { declared: true, observed: false },
|
|
5683
5786
|
rationale: args.flags.rationale ?? null,
|
|
5684
5787
|
declaredBy: process.env.USER || process.env.USERNAME || '(unspecified)',
|
|
5685
5788
|
declaredAt: new Date().toISOString(),
|
|
@@ -5798,6 +5901,8 @@ async function cmdFederateList(args) {
|
|
|
5798
5901
|
id: record?.id ?? null,
|
|
5799
5902
|
local: { nodeId: record?.local?.nodeId ?? null, stillValid: localStillValid },
|
|
5800
5903
|
remote: { sourceFile: sourceFile ?? null, nodeId: record?.remote?.nodeId ?? null, ...remoteStatus },
|
|
5904
|
+
scope: record?.scope ?? null,
|
|
5905
|
+
evidence: record?.evidence ?? { declared: true, observed: false },
|
|
5801
5906
|
rationale: record?.rationale ?? null,
|
|
5802
5907
|
declaredBy: record?.declaredBy ?? null,
|
|
5803
5908
|
declaredAt: record?.declaredAt ?? null,
|
|
@@ -6952,6 +7057,13 @@ async function main() {
|
|
|
6952
7057
|
runStdio({ sessionRoot: path.resolve(root) });
|
|
6953
7058
|
return;
|
|
6954
7059
|
}
|
|
7060
|
+
case 'lsp': {
|
|
7061
|
+
// The same server as bin/agentic-security-lsp.js, reachable from the self-contained bundle: a package that ships only the bundle
|
|
7062
|
+
// (the Nix package does) has no node_modules for the src/ entry point to import from.
|
|
7063
|
+
const { startLspServer } = await import('../src/lsp/server.js');
|
|
7064
|
+
startLspServer();
|
|
7065
|
+
return;
|
|
7066
|
+
}
|
|
6955
7067
|
case 'explore': process.exit(await cmdExplore(args));
|
|
6956
7068
|
case 'dataflow': {
|
|
6957
7069
|
const sub = args._[1];
|
package/dist/1444.index.js
CHANGED
|
@@ -8,9 +8,10 @@ export const modules = {
|
|
|
8
8
|
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
|
|
9
9
|
/* harmony export */ sweepGitHistory: () => (/* binding */ sweepGitHistory)
|
|
10
10
|
/* harmony export */ });
|
|
11
|
-
/* unused harmony exports extractAddedLines, scanHistoryDiff */
|
|
11
|
+
/* unused harmony exports extractAddedLines, splitDiffByFile, scanHistoryDiff */
|
|
12
12
|
/* harmony import */ var node_child_process__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(31421);
|
|
13
13
|
/* harmony import */ var _util_git_hardening_js__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(78844);
|
|
14
|
+
/* harmony import */ var _language_secrets_js__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(95108);
|
|
14
15
|
// R15 (PRD §5) — git-history secret sweep.
|
|
15
16
|
//
|
|
16
17
|
// A secret removed from HEAD but present in any past commit is still
|
|
@@ -27,6 +28,7 @@ export const modules = {
|
|
|
27
28
|
|
|
28
29
|
|
|
29
30
|
|
|
31
|
+
|
|
30
32
|
// Pull the post-image (added) lines out of a unified diff: lines starting with
|
|
31
33
|
// a single '+' (not the '+++' file header). Returns reconstructed text.
|
|
32
34
|
function extractAddedLines(diffText) {
|
|
@@ -37,25 +39,42 @@ function extractAddedLines(diffText) {
|
|
|
37
39
|
return out.join('\n');
|
|
38
40
|
}
|
|
39
41
|
|
|
42
|
+
// Split a unified diff into per-file sections with the POST-IMAGE line number of every added line, so a
|
|
43
|
+
// finding can name the file and line it was committed at (the combined text alone loses both).
|
|
44
|
+
function splitDiffByFile(diffText) {
|
|
45
|
+
const files = [];
|
|
46
|
+
let cur = null; let next = 0;
|
|
47
|
+
for (const line of String(diffText || '').split('\n')) {
|
|
48
|
+
const h = /^diff --git a\/(.+?) b\/(.+)$/.exec(line);
|
|
49
|
+
if (h) { cur = { path: h[2], added: [] }; files.push(cur); next = 0; continue; }
|
|
50
|
+
if (!cur) continue;
|
|
51
|
+
const hh = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
52
|
+
if (hh) { next = parseInt(hh[1], 10); continue; }
|
|
53
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue;
|
|
54
|
+
if (line.startsWith('+')) { cur.added.push({ text: line.slice(1), line: next }); next++; }
|
|
55
|
+
else if (!line.startsWith('-') && !line.startsWith('\\')) next++;
|
|
56
|
+
}
|
|
57
|
+
return files;
|
|
58
|
+
}
|
|
59
|
+
|
|
40
60
|
// Pure: run the injected credential detector over a commit's added lines.
|
|
41
|
-
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[].
|
|
42
|
-
|
|
61
|
+
// detectFn has the scanCredentials(fp, raw) shape and returns Finding[]. `extra` are additional detectors
|
|
62
|
+
// ((path, text) => Finding[]) run against each file's added lines WITH the real path, so extension-gated
|
|
63
|
+
// detectors (Haskell/Nix split-secret) apply to history too.
|
|
64
|
+
function scanHistoryDiff(diffText, commit, detectFn, extra = []) {
|
|
43
65
|
if (typeof detectFn !== 'function') return [];
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
...f,
|
|
66
|
+
const sections = splitDiffByFile(diffText);
|
|
67
|
+
const remediation = 'Rotate the credential now, then purge it from history (git filter-repo / BFG) and move it to a secrets manager. Removing it from HEAD alone is insufficient.';
|
|
68
|
+
const wrap = (f, sec, addedLines) => {
|
|
69
|
+
const hit = Number.isInteger(f.line) && f.line >= 1 ? addedLines[f.line - 1] : null;
|
|
70
|
+
const prov = f.rotation ? {} : (0,_language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .providerInfo */ .cf)(f._rawProviderValue || '');
|
|
71
|
+
const out = {
|
|
72
|
+
...f, ...prov,
|
|
52
73
|
id: `secret-history:${commit}:${f.id || f.vuln || 'secret'}`,
|
|
53
|
-
file: `git-history@${commit}`,
|
|
54
|
-
line:
|
|
55
|
-
commit,
|
|
56
|
-
_historical: true,
|
|
74
|
+
file: `git-history@${commit}`, line: 0, commit, _historical: true,
|
|
75
|
+
...(sec ? { sourceFile: sec.path, sourceLine: hit ? hit.line : null } : {}),
|
|
57
76
|
vuln: `${f.vuln || 'Hardcoded Secret'} (in git history)`,
|
|
58
|
-
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
77
|
+
description: `${f.description || 'A credential was committed.'} Found in commit ${commit}${sec ? ` (${sec.path}${hit ? `:${hit.line}` : ''})` : ''}; even if removed from HEAD it remains recoverable from git and must be rotated.`,
|
|
59
78
|
remediation,
|
|
60
79
|
// report/index.js's _remediationOf checks `.fix` before `.remediation`
|
|
61
80
|
// — the underlying detector already set `.fix` to a generic "remove
|
|
@@ -64,15 +83,41 @@ function scanHistoryDiff(diffText, commit, detectFn) {
|
|
|
64
83
|
// insufficient") in every report format.
|
|
65
84
|
fix: remediation,
|
|
66
85
|
};
|
|
67
|
-
|
|
86
|
+
delete out._rawProviderValue;
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
const results = [];
|
|
90
|
+
if (!sections.length) {
|
|
91
|
+
const added = extractAddedLines(diffText);
|
|
92
|
+
if (!added.trim()) return [];
|
|
93
|
+
let findings = [];
|
|
94
|
+
try { findings = detectFn(`git-history@${commit}`, added) || []; } catch { return []; }
|
|
95
|
+
return findings.map((f) => wrap(f, null, []));
|
|
96
|
+
}
|
|
97
|
+
for (const sec of sections) {
|
|
98
|
+
if (!sec.added.length) continue;
|
|
99
|
+
const text = sec.added.map((a) => a.text).join('\n');
|
|
100
|
+
if (!text.trim()) continue;
|
|
101
|
+
let findings = [];
|
|
102
|
+
try { findings = detectFn(`git-history@${commit}`, text) || []; } catch { findings = []; }
|
|
103
|
+
for (const f of findings) results.push(wrap(f, sec, sec.added));
|
|
104
|
+
for (const ex of extra || []) {
|
|
105
|
+
let more = [];
|
|
106
|
+
try { more = ex(sec.path, text) || []; } catch { more = []; }
|
|
107
|
+
for (const f of more) results.push(wrap(f, sec, sec.added));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return results;
|
|
68
111
|
}
|
|
69
112
|
|
|
113
|
+
const defaultExtra = [_language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .scanLanguageSecretConcat */ ._d, _language_secrets_js__WEBPACK_IMPORTED_MODULE_2__/* .scanDependencyUrlCredentials */ .y6];
|
|
114
|
+
|
|
70
115
|
/**
|
|
71
116
|
* Sweep up to `maxCommits` of recent history for secrets. Best-effort: returns
|
|
72
117
|
* [] when `scanRoot` is not a git repo or git is unavailable. Dedups a secret
|
|
73
118
|
* that recurs across commits to its earliest sighting.
|
|
74
119
|
*/
|
|
75
|
-
function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000 } = {}) {
|
|
120
|
+
function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 20000, extraDetectors = null } = {}) {
|
|
76
121
|
if (!scanRoot || typeof detectFn !== 'function') return [];
|
|
77
122
|
let out;
|
|
78
123
|
try {
|
|
@@ -91,7 +136,7 @@ function sweepGitHistory(scanRoot, detectFn, { maxCommits = 50, timeoutMs = 2000
|
|
|
91
136
|
const seen = new Set();
|
|
92
137
|
for (let i = 1; i < parts.length; i += 2) {
|
|
93
138
|
const sha = (parts[i] || '').slice(0, 12);
|
|
94
|
-
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn)) {
|
|
139
|
+
for (const f of scanHistoryDiff(parts[i + 1] || '', sha, detectFn, extraDetectors || defaultExtra)) {
|
|
95
140
|
const key = `${f.vuln}:${(f.snippet || f.match || '').slice(0, 40)}`;
|
|
96
141
|
if (seen.has(key)) continue;
|
|
97
142
|
seen.add(key);
|
package/dist/1905.index.js
CHANGED
|
@@ -13,6 +13,8 @@ __webpack_require__.d(__webpack_exports__, {
|
|
|
13
13
|
|
|
14
14
|
// UNUSED EXPORTS: makeBudget, makeTaintProbe
|
|
15
15
|
|
|
16
|
+
// EXTERNAL MODULE: ./src/language/discovery.js
|
|
17
|
+
var discovery = __webpack_require__(30951);
|
|
16
18
|
// EXTERNAL MODULE: external "node:crypto"
|
|
17
19
|
var external_node_crypto_ = __webpack_require__(77598);
|
|
18
20
|
;// CONCATENATED MODULE: ./src/discovery/partition.js
|
|
@@ -32,6 +34,7 @@ var external_node_crypto_ = __webpack_require__(77598);
|
|
|
32
34
|
// comes straight back.
|
|
33
35
|
|
|
34
36
|
|
|
37
|
+
|
|
35
38
|
function focusAreaId(files) {
|
|
36
39
|
const canon = [...new Set(files || [])].sort().join('\n');
|
|
37
40
|
return external_node_crypto_.createHash('sha256').update(canon).digest('hex').slice(0, 12);
|
|
@@ -109,6 +112,33 @@ function partitionCallGraph(callGraph, opts = {}) {
|
|
|
109
112
|
return areas;
|
|
110
113
|
}
|
|
111
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Focus areas for Nix configuration, which has no call graph: files joined by `imports` / path references form one
|
|
117
|
+
* area, so a NixOS entry point and the modules that decide its options are hunted together. Same shape as a call-graph
|
|
118
|
+
* area (`functions` is empty: a Nix file has none).
|
|
119
|
+
*/
|
|
120
|
+
function partitionNixFiles(fileContents, opts = {}) {
|
|
121
|
+
const maxAreas = Number.isInteger(opts.maxAreas) && opts.maxAreas > 0 ? opts.maxAreas : 8;
|
|
122
|
+
const nix = {};
|
|
123
|
+
for (const [f, t] of Object.entries(fileContents || {})) if (/\.nix$/i.test(f) && typeof t === 'string') nix[f] = t;
|
|
124
|
+
const files = Object.keys(nix).sort();
|
|
125
|
+
if (!files.length) return [];
|
|
126
|
+
const { graph } = (0,discovery/* buildImportGraph */.oK)(nix);
|
|
127
|
+
const dsu = makeDSU();
|
|
128
|
+
for (const f of files) dsu.find(f);
|
|
129
|
+
for (const [f, deps] of graph) for (const d of deps) dsu.union(f, d);
|
|
130
|
+
const groups = new Map();
|
|
131
|
+
for (const f of files) { const r = dsu.find(f); if (!groups.has(r)) groups.set(r, []); groups.get(r).push(f); }
|
|
132
|
+
let areas = [...groups.values()].map((g) => { const sorted = [...g].sort(); return { id: focusAreaId(sorted), label: labelFor(sorted), files: sorted, functions: [], size: sorted.length, language: 'nix' }; });
|
|
133
|
+
areas.sort((a, b) => b.size - a.size || (a.id < b.id ? -1 : 1));
|
|
134
|
+
if (areas.length > maxAreas) {
|
|
135
|
+
const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1).flatMap((a) => a.files).sort();
|
|
136
|
+
kept.push({ id: focusAreaId(tail), label: 'misc', files: tail, functions: [], size: tail.length, language: 'nix' });
|
|
137
|
+
areas = kept;
|
|
138
|
+
}
|
|
139
|
+
return areas;
|
|
140
|
+
}
|
|
141
|
+
|
|
112
142
|
// EXTERNAL MODULE: ./src/discovery/lenses.js
|
|
113
143
|
var discovery_lenses = __webpack_require__(33499);
|
|
114
144
|
// EXTERNAL MODULE: ./src/egress/policy.js
|
|
@@ -1057,7 +1087,16 @@ function makeBudget(opts = {}, now = Date.now) {
|
|
|
1057
1087
|
}
|
|
1058
1088
|
|
|
1059
1089
|
async function runDiscovery(ctx = {}, opts = {}) {
|
|
1060
|
-
|
|
1090
|
+
// Call-graph areas (every language the IR covers, Haskell included) plus Nix configuration areas, which have no call
|
|
1091
|
+
// graph. Together they respect the same ceiling.
|
|
1092
|
+
const maxAreas = opts.maxAreas ?? 8;
|
|
1093
|
+
let areas = [...partitionCallGraph(ctx.callGraph, { maxAreas }), ...partitionNixFiles(ctx.fileContents, { maxAreas })];
|
|
1094
|
+
if (areas.length > maxAreas) {
|
|
1095
|
+
const kept = areas.slice(0, maxAreas - 1); const tail = areas.slice(maxAreas - 1);
|
|
1096
|
+
const files = [...new Set(tail.flatMap((a) => a.files))].sort();
|
|
1097
|
+
kept.push({ id: tail.map((a) => a.id).join('+').slice(0, 64), label: 'misc', files, functions: tail.flatMap((a) => a.functions), size: tail.reduce((n, a) => n + a.size, 0) });
|
|
1098
|
+
areas = kept;
|
|
1099
|
+
}
|
|
1061
1100
|
|
|
1062
1101
|
const reasons = [];
|
|
1063
1102
|
const budget = makeBudget(opts);
|
|
@@ -1095,7 +1134,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
1095
1134
|
for (const area of areas) {
|
|
1096
1135
|
let areaDegradedCount = 0;
|
|
1097
1136
|
for (const lens of lenses) {
|
|
1098
|
-
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {} }, { llmInvoke, scanRoot: opts.scanRoot });
|
|
1137
|
+
const run = await runHunter(area, lens, { fileContents: ctx.fileContents || {}, scanRoot: opts.scanRoot }, { llmInvoke, scanRoot: opts.scanRoot });
|
|
1099
1138
|
runs.push({ focusAreaId: run.focusAreaId, lens: run.lens, degraded: run.degraded, reason: run.reason, candidateCount: run.candidates.length });
|
|
1100
1139
|
if (run.degraded && run.reason) reasons.push(`${area.label} × ${lens.key}: ${run.reason}`);
|
|
1101
1140
|
if (run.degraded) areaDegradedCount += 1;
|
|
@@ -1267,6 +1306,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
1267
1306
|
/* harmony export */ LENSES: () => (/* binding */ LENSES),
|
|
1268
1307
|
/* harmony export */ j: () => (/* binding */ buildHunterPrompt)
|
|
1269
1308
|
/* harmony export */ });
|
|
1309
|
+
/* harmony import */ var _egress_redact_js__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(74831);
|
|
1270
1310
|
//
|
|
1271
1311
|
// The seven hunting lenses. Each hunter run is one (focus area × lens) pair.
|
|
1272
1312
|
//
|
|
@@ -1276,6 +1316,7 @@ async function runDiscovery(ctx = {}, opts = {}) {
|
|
|
1276
1316
|
// same code than one told to look at crypto, so the union covers failure modes
|
|
1277
1317
|
// no single prompt reaches. `wildcard` exists because a fixed taxonomy is a
|
|
1278
1318
|
// ceiling, and the classes worth finding are the ones not on the list.
|
|
1319
|
+
|
|
1279
1320
|
const LENSES = Object.freeze([
|
|
1280
1321
|
{ key: 'injection', title: 'Injection', family: 'injection', cwe: 'CWE-74',
|
|
1281
1322
|
brief: 'Untrusted input reaching an interpreter: SQL, shell, template, XPath, LDAP, or deserialization. Follow the value, not the function name.' },
|
|
@@ -1308,7 +1349,9 @@ function buildHunterPrompt(focusArea, lens, ctx = {}) {
|
|
|
1308
1349
|
let budget = maxChars;
|
|
1309
1350
|
const blocks = [];
|
|
1310
1351
|
for (const f of files) {
|
|
1311
|
-
|
|
1352
|
+
// Everything that leaves the machine is redacted first (secrets, PII/PHI/PCI-shaped fields, operator-declared
|
|
1353
|
+
// customer data, and whole proprietary paths), by the same choke point the validator and the proposers use.
|
|
1354
|
+
const src = (0,_egress_redact_js__WEBPACK_IMPORTED_MODULE_0__/* .redactPayload */ .cy)({ text: contents[f], filePath: f, scanRoot: ctx.scanRoot || null }).text;
|
|
1312
1355
|
const slice = src.length > budget ? src.slice(0, Math.max(0, budget)) : src;
|
|
1313
1356
|
const truncated = slice.length < src.length;
|
|
1314
1357
|
blocks.push(`--- SOURCE FILE (untrusted data): ${f}${truncated ? ' (truncated)' : ''} ---\n${slice}\n--- END SOURCE FILE: ${f} ---`);
|